mirror of
https://github.com/suitenumerique/meet.git
synced 2026-11-06 03:01:39 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6beb8ed3e | |||
| 505c55d3b9 | |||
| 72d964656c | |||
| 58bf9a7d82 | |||
| 72af9d629e | |||
| 3cd2cb5591 | |||
| fcd37b0f26 | |||
| b6a8ae7d35 | |||
| 68848c0d6f | |||
| 2ea5c85436 | |||
| 57a4446710 | |||
| df723e6a0e | |||
| 900ebd3edd | |||
| c4fbfdd302 | |||
| 6f1b9ddba7 | |||
| e412dec76f | |||
| 6af43ebf1d | |||
| 4e953da226 |
@@ -21,3 +21,6 @@ CVE-2026-9538 exp:2026-12-05
|
||||
|
||||
# libsqlite3-0 (fixed in sid 3.54.1-1; trixie marked no-dsa / minor)
|
||||
CVE-2026-119373 exp:2026-12-05
|
||||
|
||||
# zlib (fixed in sid 1:1.3.dfsg+really1.3.2-4; trixie still vulnerable)
|
||||
CVE-2026-76844 exp:2026-12-05
|
||||
|
||||
@@ -22,11 +22,19 @@ and this project adheres to
|
||||
- ⬆️(backend) update python dependencies
|
||||
- ⬆️(agents) update python dependencies
|
||||
- ⬆️(summary) update python dependencies
|
||||
- 🔒️(backend) issue room access tokens via POST rooms/{id}/join/ only
|
||||
- 🔒️(frontend) send the display name in the join request body
|
||||
- 🔥(backend) drop is_administrable from unregistered room payloads
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) show a page when the connection ends unexpectedly
|
||||
- 🐛(frontend) show the transcript mic hint when every mic is muted
|
||||
- 🐛(frontend) show join errors instead of silently waiting in the lobby
|
||||
- 🐛(frontend) send users who cannot enter directly back to the lobby
|
||||
- 🔒️(backend) refuse unregistered rooms whose slug is a UUID
|
||||
- 🔒️(agents) upgrade python to 3.14.10 to fix CVE-2026-109518
|
||||
- ♿️(frontend) close the invite dialog with the Escape key #1916
|
||||
|
||||
## [1.37.0] - 2026-11-01
|
||||
|
||||
@@ -38,6 +46,13 @@ and this project adheres to
|
||||
- ⬆️(backend) upgrade django-lasuite to v1.0.0
|
||||
- ⚡️(docker) build frontend assets natively for arm64 images
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(summary) protect signed URLs in models, source logs and analytics metadata
|
||||
- 🔒️(summary) mask secret validation inputs and sanitize upstream and task errors
|
||||
- 🔒️(summary) scrub sensitive Sentry event and breadcrumb content while retaining
|
||||
redacted frame locals and application DEBUG diagnostics
|
||||
|
||||
## [1.36.3] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
+29
@@ -16,6 +16,35 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
### Room access tokens moved to POST /join/
|
||||
|
||||
LiveKit access tokens are no longer returned when reading a room. They are henceforth issued by a dedicated `POST
|
||||
/api/v1.0/rooms/{id}/join/` endpoint, which receives the display name in its JSON body. Display names therefore no
|
||||
longer appear in URLs and token retrieval by logged-in users is now protected against CSRF.
|
||||
|
||||
- `GET /api/v1.0/rooms/{id}/` no longer returns the `livekit` block, and neither do the list, create and update
|
||||
responses
|
||||
- The `username` field of `POST /api/v1.0/rooms/{id}/request-entry/` is now limited to 255 characters
|
||||
- When the caller may enter the room directly, the response contains the room data plus a `livekit` block
|
||||
- When the caller must go through the lobby, the response has no `livekit` block
|
||||
- Session-authenticated callers must send the CSRF token (`X-CSRFToken` header and `csrftoken` cookie)
|
||||
- Every join returning a `livekit` block mints a new token, valid for 6 hours, and anonymous callers get a new
|
||||
participant identity each time.
|
||||
- Joining again returns no `livekit` block to callers admitted through the lobby.
|
||||
- Unregistered rooms are always entered directly: joining one always returns a `livekit` block.
|
||||
- Unregistered rooms are no longer described with an `is_administrable` field,
|
||||
|
||||
### Unregistered rooms can no longer be named after a UUID
|
||||
|
||||
When `ALLOW_UNREGISTERED_ROOMS` is enabled, reading or joining a room whose identifier normalizes to an empty slug or to
|
||||
a UUID now returns a 404 instead of describing or letting callers join the unregistered room.
|
||||
|
||||
This closes a lobby bypass since a registered LiveKit room is named after its room id. An identifier crafted to not
|
||||
parse as a UUID, yet to normalize to an existing room's id, matched no registered room and was treated as an
|
||||
unregistered one. The token minted for that unregistered room was valid for the registered room: anyone could join a
|
||||
trusted or restricted room without being admitted through the lobby.
|
||||
|
||||
## v1.37.0
|
||||
|
||||
### Crisp support chat replaced by the La Suite support form
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14.8-slim AS base
|
||||
FROM python:3.14.10-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||
|
||||
@@ -27,9 +27,12 @@ from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISPLAY_NAME_MAX_LENGTH = 255
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
@@ -170,17 +173,18 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
Add LiveKit credentials for public instance or related users/groups
|
||||
"""
|
||||
"""Add accesses only for administrator and owner users."""
|
||||
output = super().to_representation(instance)
|
||||
request = self.context.get("request")
|
||||
|
||||
if not request:
|
||||
return output
|
||||
|
||||
role = instance.get_role(request.user)
|
||||
if "role" in self.context:
|
||||
role = self.context["role"]
|
||||
else:
|
||||
role = instance.get_role(request.user)
|
||||
|
||||
is_admin_or_owner = models.RoleChoices.check_administrator_role(
|
||||
role
|
||||
) or models.RoleChoices.check_owner_role(role)
|
||||
@@ -193,26 +197,9 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
should_access_room = (
|
||||
(
|
||||
instance.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and request.user.is_authenticated
|
||||
)
|
||||
or role is not None
|
||||
or instance.is_public
|
||||
)
|
||||
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
if not LobbyService.can_bypass_lobby(
|
||||
room=instance, user=request.user, role=role
|
||||
):
|
||||
del output["pin_code"]
|
||||
|
||||
return output
|
||||
@@ -355,10 +342,23 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
return value
|
||||
|
||||
|
||||
class JoinRoomSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate the data sent to join a room."""
|
||||
|
||||
username = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=DISPLAY_NAME_MAX_LENGTH,
|
||||
help_text="Display name in the room. The default name is used when it "
|
||||
"is missing, null or blank.",
|
||||
)
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
username = serializers.CharField(required=True, max_length=DISPLAY_NAME_MAX_LENGTH)
|
||||
participant_id = serializers.CharField(
|
||||
required=False, allow_null=True, max_length=128
|
||||
)
|
||||
@@ -675,7 +675,9 @@ class RaiseHandSerializer(BaseValidationOnlySerializer):
|
||||
class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
||||
"""Serializer for renaming a participant in a room."""
|
||||
|
||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||
name = serializers.CharField(
|
||||
min_length=1, max_length=DISPLAY_NAME_MAX_LENGTH, allow_blank=False
|
||||
)
|
||||
|
||||
|
||||
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
|
||||
@@ -309,35 +309,35 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
ensure_room_not_deleted(obj)
|
||||
return obj
|
||||
|
||||
def _get_object_or_unregistered_slug(self):
|
||||
"""Return the requested room, or the slug of an unregistered room."""
|
||||
try:
|
||||
return self.get_object(), None
|
||||
except Http404:
|
||||
slug = slugify(self.kwargs["pk"])
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS or not slug or utils.is_uuid(slug):
|
||||
raise
|
||||
return None, slug
|
||||
|
||||
@staticmethod
|
||||
def _get_unregistered_room_data(slug):
|
||||
"""Describe an unregistered room, which only exists by its slug."""
|
||||
return {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
}
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
Allow unregistered rooms when activated.
|
||||
For unregistered rooms we only return a null id and the livekit room and token.
|
||||
LiveKit credentials are never returned: they are obtained by joining.
|
||||
"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
except Http404:
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||
raise
|
||||
slug = slugify(self.kwargs["pk"])
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"is_administrable": False,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
"token": utils.generate_token(
|
||||
room=slug, user=request.user, username=username
|
||||
),
|
||||
},
|
||||
}
|
||||
else:
|
||||
data = self.get_serializer(instance).data
|
||||
room, slug = self._get_object_or_unregistered_slug()
|
||||
if room is None:
|
||||
return drf_response.Response(self._get_unregistered_room_data(slug))
|
||||
|
||||
return drf_response.Response(data)
|
||||
return drf_response.Response(self.get_serializer(room).data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||
@@ -572,6 +572,55 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
{"message": f"Recording stopped for room {room.slug}."}
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="join",
|
||||
permission_classes=[],
|
||||
)
|
||||
def join(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Join a room, getting LiveKit credentials when allowed to enter directly.
|
||||
|
||||
The response contains the room with a livekit object holding the url,
|
||||
room and token to connect with when the requester may enter without
|
||||
going through the lobby. Otherwise, it has no livekit object and the
|
||||
client should request entry through the lobby.
|
||||
|
||||
Unregistered rooms, when allowed, are always joined directly.
|
||||
"""
|
||||
|
||||
serializer = serializers.JoinRoomSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
username = serializer.validated_data.get("username") or None
|
||||
|
||||
room, slug = self._get_object_or_unregistered_slug()
|
||||
if room is None:
|
||||
return drf_response.Response(
|
||||
{
|
||||
**self._get_unregistered_room_data(slug),
|
||||
"livekit": utils.generate_livekit_config(
|
||||
room_id=slug, user=request.user, username=username
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
role = room.get_role(request.user)
|
||||
data = self.get_serializer(
|
||||
room, context={**self.get_serializer_context(), "role": role}
|
||||
).data
|
||||
|
||||
if LobbyService.can_bypass_lobby(room=room, user=request.user, role=role):
|
||||
data["livekit"] = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=data["configuration"],
|
||||
role=role,
|
||||
)
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -66,6 +66,8 @@ def test_api_rooms_create_authenticated(reset_cache):
|
||||
assert room.name == "my room"
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
assert "livekit" not in response.json()
|
||||
assert response.json()["pin_code"] == room.pin_code
|
||||
|
||||
rooms_data = cache.keys("room-creation-callback_*")
|
||||
assert not rooms_data
|
||||
@@ -495,3 +497,4 @@ def test_api_rooms_create_authenticated_with_user_access_token():
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
assert "livekit" not in response.json()
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: join.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
from ..utils import generate_user_access_token
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
livekit_token = fake.sha256()
|
||||
|
||||
ANONYMOUS = "anonymous"
|
||||
NO_ROLE = "no-role"
|
||||
|
||||
# Who may join a room directly, without going through the lobby
|
||||
# room.get_role(user) returns None both for an anonymous user and for an
|
||||
# authenticated user without access, hence the NO_ROLE
|
||||
ACCESS_MATRIX = [
|
||||
(RoomAccessLevel.PUBLIC, ANONYMOUS, True),
|
||||
(RoomAccessLevel.TRUSTED, ANONYMOUS, False),
|
||||
(RoomAccessLevel.RESTRICTED, ANONYMOUS, False),
|
||||
(RoomAccessLevel.PUBLIC, NO_ROLE, True),
|
||||
(RoomAccessLevel.TRUSTED, NO_ROLE, True),
|
||||
(RoomAccessLevel.RESTRICTED, NO_ROLE, False),
|
||||
*[
|
||||
(access_level, role, True)
|
||||
for access_level in RoomAccessLevel
|
||||
for role in RoleChoices
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def decode_token(token):
|
||||
"""Decode a LiveKit access token for inspection."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
|
||||
def join_url(room_id):
|
||||
"""Return the url to join a room."""
|
||||
return f"/api/v1.0/rooms/{room_id!s}/join/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access_level", "requester", "can_join_directly"), ACCESS_MATRIX
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_access(mock_token, access_level, requester, can_join_directly):
|
||||
"""
|
||||
Joining a room should return LiveKit credentials and the pin code only to
|
||||
users who may enter without going through the lobby, and the accesses
|
||||
only to the room's administrators and owners.
|
||||
"""
|
||||
room = RoomFactory(
|
||||
access_level=access_level,
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
client = APIClient()
|
||||
user = AnonymousUser()
|
||||
username = fake.user_name()
|
||||
|
||||
role = None
|
||||
if requester != ANONYMOUS:
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
if requester != NO_ROLE:
|
||||
role = requester
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": username}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
accesses = content.pop("accesses", None)
|
||||
|
||||
if role in (RoleChoices.ADMIN, RoleChoices.OWNER):
|
||||
assert {access["id"] for access in accesses} == {
|
||||
str(access.id) for access in room.accesses.all()
|
||||
}
|
||||
else:
|
||||
assert accesses is None
|
||||
|
||||
expected = {
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(access_level),
|
||||
}
|
||||
if can_join_directly:
|
||||
expected["pin_code"] = room.pin_code
|
||||
expected["livekit"] = {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": str(room.id),
|
||||
"token": livekit_token,
|
||||
}
|
||||
assert content == expected
|
||||
|
||||
if can_join_directly:
|
||||
mock_token.assert_called_once_with(
|
||||
room=str(room.id),
|
||||
user=user,
|
||||
username=username,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=role,
|
||||
participant_id=None,
|
||||
)
|
||||
else:
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_by_slug(mock_token):
|
||||
"""It should be possible to join a room by its slug, not normalized."""
|
||||
room = RoomFactory(name="Réunion", access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(join_url("Réunion"), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == str(room.id)
|
||||
assert response.json()["livekit"]["room"] == str(room.id)
|
||||
mock_token.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_members_num_queries(mock_token, django_assert_num_queries):
|
||||
"""The role of the requester should only be looked up once."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=room, user=user, role=RoleChoices.MEMBER)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
response = client.post(join_url(room.id), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
assert mock_token.call_args.kwargs["role"] == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.OWNER])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_administrators_num_queries(
|
||||
mock_token, role, django_assert_num_queries
|
||||
):
|
||||
"""Only the accesses should be queried on top of the role for administrators."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
response = client.post(join_url(room.id), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
assert "accesses" in response.json()
|
||||
assert mock_token.call_args.kwargs["role"] == role
|
||||
|
||||
|
||||
def test_api_rooms_join_get_not_allowed():
|
||||
"""Joining a room should only be possible with a POST request."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().get(join_url(room.id))
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("authenticated", [False, True])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_allowed(mock_token, authenticated, settings):
|
||||
"""
|
||||
Joining an unregistered room should return LiveKit credentials for the
|
||||
room named after the slug, when unregistered rooms are allowed.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
client = APIClient()
|
||||
user = AnonymousUser()
|
||||
username = fake.user_name()
|
||||
if authenticated:
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
join_url("unregistered-room"), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": "unregistered-room",
|
||||
"token": livekit_token,
|
||||
},
|
||||
}
|
||||
mock_token.assert_called_once_with(
|
||||
room="unregistered-room",
|
||||
user=user,
|
||||
username=username,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_allowed_not_normalized(mock_token, settings):
|
||||
"""
|
||||
Joining an unregistered room by a slug that is not normalized should use
|
||||
the LiveKit room named after the url-safe slug.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
|
||||
response = APIClient().post(join_url("Réunion"), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": "reunion",
|
||||
"token": livekit_token,
|
||||
},
|
||||
}
|
||||
mock_token.assert_called_once_with(
|
||||
room="reunion",
|
||||
user=AnonymousUser(),
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_join_unregistered_token_claims(settings):
|
||||
"""The token of an unregistered room should be scoped to it and carry the name."""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
username = fake.user_name()
|
||||
|
||||
response = APIClient().post(
|
||||
join_url("unregistered-room"), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
assert claims["video"]["room"] == "unregistered-room"
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_not_allowed(mock_token, settings):
|
||||
"""
|
||||
Joining an unregistered room should return a 404 if unregistered rooms
|
||||
are not allowed.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = False
|
||||
|
||||
response = APIClient().post(join_url("unregistered-room"), {}, format="json")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lookup", ["---", "!!!", "_"])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_empty_slug(mock_token, lookup, settings):
|
||||
"""
|
||||
Joining an unregistered room whose slug is empty once normalized should
|
||||
return a 404 rather than credentials for a room with no name.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
|
||||
response = APIClient().post(join_url(lookup), {}, format="json")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", ["{}!", "_{}", "{}~", "{}!!"])
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.RESTRICTED]
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_uuid_slug(
|
||||
mock_token, access_level, template, settings
|
||||
):
|
||||
"""
|
||||
Joining an unregistered room whose slug is a UUID once normalized should
|
||||
return a 404 rather than credentials for the registered room with that id.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(template.format(str(room.id).upper())), {}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_soft_deleted(
|
||||
mock_token, lookup, allow_unregistered_rooms, settings
|
||||
):
|
||||
"""
|
||||
Joining a soft-deleted room should return a 410, and never fall back
|
||||
to an unregistered room with the same slug.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
room.soft_delete()
|
||||
|
||||
response = APIClient().post(join_url(getattr(room, lookup)), {}, format="json")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected_username"),
|
||||
[
|
||||
({"username": "Jean Bon"}, "Jean Bon"),
|
||||
({"username": " Jean Bon "}, "Jean Bon"),
|
||||
({"username": "x" * 255}, "x" * 255),
|
||||
({}, None),
|
||||
({"username": None}, None),
|
||||
({"username": ""}, None),
|
||||
({"username": " "}, None),
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_username(mock_token, payload, expected_username):
|
||||
"""
|
||||
The trimmed display name should be passed on to the token, a missing or
|
||||
blank one leaving the default name to the token generation.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(join_url(room.id), payload, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["livekit"]["token"] == livekit_token
|
||||
assert mock_token.call_args.kwargs["username"] == expected_username
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_username_too_long(mock_token):
|
||||
"""A display name exceeding 255 characters should be rejected."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(room.id), {"username": "a" * 256}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"username": ["Ensure this field has no more than 255 characters."]
|
||||
}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_username_in_query_string_ignored(mock_token):
|
||||
"""The display name should only be read from the request body."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
username = fake.user_name()
|
||||
|
||||
response = APIClient().post(
|
||||
f"{join_url(room.id)}?username={username}", {}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_token.call_args.kwargs["username"] is None
|
||||
|
||||
|
||||
def test_api_rooms_join_token_claims_anonymous():
|
||||
"""The token should carry the submitted display name and be scoped to the room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
username = fake.user_name()
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(room.id), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
assert claims["video"]["room"] == str(room.id)
|
||||
assert claims["video"]["roomAdmin"] is False
|
||||
|
||||
|
||||
def test_api_rooms_join_token_claims_anonymous_default_name():
|
||||
"""An anonymous user joining without a display name should get the default one."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(join_url(room.id), {"username": ""}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == "Anonymous"
|
||||
|
||||
|
||||
def test_api_rooms_join_token_claims_owner():
|
||||
"""The token of an owner should carry the submitted name and the room admin grant."""
|
||||
username = fake.user_name()
|
||||
user = UserFactory(full_name=fake.name())
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": username}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
assert claims["sub"] == str(user.sub)
|
||||
assert claims["video"]["roomAdmin"] is True
|
||||
|
||||
|
||||
def test_api_rooms_join_display_name_edit_disabled(settings):
|
||||
"""
|
||||
An authenticated user should keep their account's name when display name
|
||||
edition is disabled, whatever name they submit.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
full_name = f"{fake.first_name()} {fake.last_name()}"
|
||||
user = UserFactory(full_name=full_name)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": "Impostor"}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == full_name
|
||||
|
||||
|
||||
def test_api_rooms_join_display_name_edit_disabled_anonymous(settings):
|
||||
"""Anonymous users should still choose their display name when edition is disabled."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
username = fake.user_name()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(room.id), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
|
||||
|
||||
def test_api_rooms_join_session_without_csrf_token():
|
||||
"""A session-authenticated request without a CSRF token should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": "Jane"}, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"].startswith("CSRF Failed")
|
||||
|
||||
|
||||
def test_api_rooms_join_session_with_csrf_token():
|
||||
"""A session-authenticated request with a valid CSRF token should be accepted."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.force_login(user)
|
||||
csrf_secret = get_random_string(32)
|
||||
client.cookies[settings.CSRF_COOKIE_NAME] = csrf_secret
|
||||
|
||||
response = client.post(
|
||||
join_url(room.id),
|
||||
{"username": "Jane"},
|
||||
format="json",
|
||||
HTTP_X_CSRFTOKEN=csrf_secret,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
|
||||
|
||||
def test_api_rooms_join_session_with_csrf_token_foreign_origin():
|
||||
"""A session-authenticated request from a foreign origin should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.force_login(user)
|
||||
csrf_secret = get_random_string(32)
|
||||
client.cookies[settings.CSRF_COOKIE_NAME] = csrf_secret
|
||||
|
||||
response = client.post(
|
||||
join_url(room.id),
|
||||
{"username": "Jane"},
|
||||
format="json",
|
||||
HTTP_X_CSRFTOKEN=csrf_secret,
|
||||
HTTP_ORIGIN="https://evil.example",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"].startswith("CSRF Failed: Origin checking failed")
|
||||
|
||||
|
||||
def test_api_rooms_join_anonymous_without_csrf_token():
|
||||
"""
|
||||
An anonymous request carries no ambient credential, so it should not be
|
||||
subject to CSRF checks.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": "Anonymous"}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
|
||||
|
||||
def test_api_rooms_join_user_access_token_without_csrf_token():
|
||||
"""
|
||||
A request authenticated with a user access token carries no ambient
|
||||
credential, so it should not be subject to CSRF checks.
|
||||
"""
|
||||
username = fake.user_name()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[(user, "owner")])
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.post(join_url(room.id), {"username": username}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["pin_code"] == room.pin_code
|
||||
assert "accesses" in response.json()
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["sub"] == str(user.sub)
|
||||
@@ -61,6 +61,7 @@ def test_api_rooms_list_authenticated():
|
||||
}
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
assert all("livekit" not in result for result in results)
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
@@ -172,3 +173,4 @@ def test_api_rooms_list_authenticated_with_user_access_token():
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
assert "livekit" not in response.data["results"][0]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: lobby functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613,W0212
|
||||
# pylint: disable=W0621,W0613,W0212,too-many-lines
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -360,6 +360,40 @@ def test_request_entry_invalid_data():
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_request_entry_username_too_long():
|
||||
"""Should return 400 for a username exceeding 255 characters."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
with mock.patch.object(utils, "notify_participants") as mock_notify:
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "a" * 256},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"username": ["Ensure this field has no more than 255 characters."]
|
||||
}
|
||||
mock_notify.assert_not_called()
|
||||
assert not LobbyService()._index_members(room.id)
|
||||
|
||||
|
||||
def test_request_entry_username_at_max_length():
|
||||
"""Should accept a username of exactly 255 characters."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
with mock.patch.object(utils, "notify_participants", return_value=None):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "a" * 255},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "a" * 255
|
||||
|
||||
|
||||
def test_request_entry_room_not_found():
|
||||
"""Should return 404 for non-existent room."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -366,6 +366,22 @@ def test_rename_participant_name_too_long(room, token):
|
||||
assert "name" in response.data
|
||||
|
||||
|
||||
def test_rename_participant_name_at_max_length(mock_livekit_client, room, token):
|
||||
"""Test rename accepts a name of exactly 255 characters."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "a" * 255},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].name == "a" * 255
|
||||
|
||||
|
||||
def test_rename_participant_forbidden_without_token(room):
|
||||
"""Test rename returns 403 when no LiveKit token is provided."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
"""Test rooms API endpoints in the Meet core app: retrieve."""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone as dj_timezone
|
||||
|
||||
@@ -112,18 +109,11 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
"""
|
||||
Retrieving an unregistered room should return a Livekit token
|
||||
if unregistered rooms are allowed.
|
||||
Retrieving an unregistered room should describe it if unregistered rooms
|
||||
are allowed, without any LiveKit token: those are obtained by joining.
|
||||
"""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/rooms/unregistered-room/")
|
||||
@@ -133,32 +123,17 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "unregistered-room",
|
||||
"token": "foo",
|
||||
},
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room="unregistered-room", user=AnonymousUser(), username=None
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_token):
|
||||
"""
|
||||
Getting an unregistered room by a slug that is not normalized should work
|
||||
and use the Livekit room on the url-safe name.
|
||||
and return the url-safe slug.
|
||||
"""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/rooms/Réunion/")
|
||||
@@ -168,17 +143,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "reunion",
|
||||
"token": "foo",
|
||||
},
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room="reunion", user=AnonymousUser(), username=None
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=False)
|
||||
@@ -193,6 +160,36 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@pytest.mark.parametrize("lookup", ["---", "!!!", "_"])
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_empty_slug(lookup):
|
||||
"""
|
||||
Retrieving an unregistered room whose slug is empty once normalized should
|
||||
return a 404.
|
||||
"""
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{lookup}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@pytest.mark.parametrize("template", ["{}!", "_{}", "{}~"])
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_uuid_slug(template):
|
||||
"""
|
||||
Retrieving an unregistered room whose slug is a UUID once normalized should
|
||||
return a 404.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{template.format(room.id)}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@@ -216,52 +213,33 @@ def test_api_rooms_retrieve_soft_deleted(
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"""
|
||||
Anonymous users should be able to retrieve a room with a token provided, if the room is public.
|
||||
Anonymous users should be able to retrieve a public room with its pin code,
|
||||
but should not be given any token: those are obtained by joining.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once()
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a room and get a token for a room to
|
||||
which they are not related, provided the room is public.
|
||||
Authenticated users should be allowed to retrieve a public room to which
|
||||
they are not related, with its pin code but without any token.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(
|
||||
@@ -278,44 +256,23 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a room and get a token for a room to
|
||||
which they are not related, provided the room has a trusted access_level.
|
||||
Authenticated users should be allowed to retrieve a trusted room to which
|
||||
they are not related, with its pin code but without any token.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
@@ -329,30 +286,16 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated():
|
||||
@@ -381,13 +324,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
"""
|
||||
Users who are members of a room should not be allowed to see related users.
|
||||
@@ -415,40 +351,19 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
|
||||
assert "accesses" not in content_dict
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_administrators(
|
||||
mock_token, django_assert_num_queries, settings
|
||||
):
|
||||
@@ -511,30 +426,16 @@ def test_api_rooms_retrieve_administrators(
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@@ -567,3 +468,77 @@ def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||
assert response.data["id"] == str(room.id)
|
||||
assert response.data["pin_code"] == room.pin_code
|
||||
assert "accesses" in response.data
|
||||
assert "livekit" not in response.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_never_returns_livekit_credentials(
|
||||
mock_token, role, access_level
|
||||
):
|
||||
"""Retrieving a room should never mint a LiveKit token."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_anonymous_never_returns_livekit_credentials(
|
||||
mock_token, access_level
|
||||
):
|
||||
"""An anonymous user should never get a LiveKit token by retrieving a room."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access_level", "authenticated", "role", "pin_code_exposed"),
|
||||
[
|
||||
(RoomAccessLevel.PUBLIC, False, None, True),
|
||||
(RoomAccessLevel.TRUSTED, False, None, False),
|
||||
(RoomAccessLevel.RESTRICTED, False, None, False),
|
||||
(RoomAccessLevel.PUBLIC, True, None, True),
|
||||
(RoomAccessLevel.TRUSTED, True, None, True),
|
||||
(RoomAccessLevel.RESTRICTED, True, None, False),
|
||||
(RoomAccessLevel.RESTRICTED, True, RoleChoices.MEMBER, True),
|
||||
(RoomAccessLevel.RESTRICTED, True, RoleChoices.ADMIN, True),
|
||||
(RoomAccessLevel.RESTRICTED, True, RoleChoices.OWNER, True),
|
||||
],
|
||||
)
|
||||
def test_api_rooms_retrieve_pin_code_exposure(
|
||||
access_level, authenticated, role, pin_code_exposed
|
||||
):
|
||||
"""
|
||||
The pin code should only be exposed to users who may join the room
|
||||
without going through the lobby.
|
||||
"""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
client = APIClient()
|
||||
if authenticated:
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert ("pin_code" in response.json()) is pin_code_exposed
|
||||
if pin_code_exposed:
|
||||
assert response.json()["pin_code"] == room.pin_code
|
||||
|
||||
@@ -113,6 +113,7 @@ def test_api_rooms_update_administrators(mock_update_metadata):
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
@@ -467,6 +468,7 @@ def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
|
||||
room.accesses.filter(user=user).update(role=privileged_role)
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Test utils.is_uuid
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
|
||||
from core import utils
|
||||
|
||||
fake = Faker()
|
||||
|
||||
UUID = fake.uuid4()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
UUID,
|
||||
UUID.upper(),
|
||||
UUID.replace("-", ""),
|
||||
f"{{{UUID}}}",
|
||||
f"urn:uuid:{UUID}",
|
||||
],
|
||||
ids=["canonical", "uppercase", "hex", "braces", "urn"],
|
||||
)
|
||||
def test_utils_is_uuid_true(value):
|
||||
"""Strings that parse as a UUID should be recognized."""
|
||||
assert utils.is_uuid(value) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["", "my-room", "abc-defg-hij", f"{UUID}!", f"_{UUID}", UUID[:-1]],
|
||||
ids=["empty", "slug", "room-slug", "suffix", "prefix", "truncated"],
|
||||
)
|
||||
def test_utils_is_uuid_false(value):
|
||||
"""Strings that do not parse as a UUID should not be recognized."""
|
||||
assert utils.is_uuid(value) is False
|
||||
@@ -16,7 +16,7 @@ import string
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -336,6 +336,15 @@ def generate_room_slug():
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def is_uuid(value: str) -> bool:
|
||||
"""Tell whether a value parses as a UUID."""
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type using multiple methods for better accuracy.
|
||||
|
||||
@@ -9,12 +9,8 @@ import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
@@ -30,9 +26,9 @@ export const CreateMeetingMenu = () => {
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
createRoom({ slug }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
state: { create: true },
|
||||
})
|
||||
)
|
||||
}}
|
||||
@@ -45,7 +41,7 @@ export const CreateMeetingMenu = () => {
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then(setLaterRoom)
|
||||
createRoom({ slug }).then(setLaterRoom)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
|
||||
@@ -31,7 +31,6 @@ export type ApiRoom = {
|
||||
name: string
|
||||
slug: string
|
||||
pin_code?: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
|
||||
@@ -6,15 +6,13 @@ import type { ApiRoom } from './ApiRoom'
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
return fetchApi('rooms/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export const fetchRoom = ({
|
||||
roomId,
|
||||
username,
|
||||
}: {
|
||||
roomId: string
|
||||
username?: string
|
||||
}) => {
|
||||
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
|
||||
export const fetchRoom = ({ roomId }: { roomId: string }) => {
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type JoinRoomParams = {
|
||||
roomId: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
export const joinRoom = ({ roomId, username }: JoinRoomParams) => {
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/join/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@@ -10,17 +10,16 @@ import {
|
||||
ConnectionErrorReason,
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
type Room,
|
||||
type RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import type { ApiRoom } from '../api/ApiRoom'
|
||||
import { joinRoom } from '../api/joinRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
@@ -32,7 +31,7 @@ import {
|
||||
captureMediaEvent,
|
||||
reportError,
|
||||
} from '@/features/analytics/telemetry'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { type ApiConfig, useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
@@ -48,6 +47,13 @@ import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioO
|
||||
import { ConnectionEndReason } from '@/features/rooms/utils/connectionEndReason'
|
||||
import { createLiveKitRoom } from '@/features/rooms/livekit/utils/reconnectPolicy'
|
||||
|
||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
}
|
||||
|
||||
const handleError = (e: Error) => {
|
||||
const failure = getMediaDeviceFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
@@ -66,65 +72,111 @@ const handleError = (e: Error) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const warmUpConnection = async (room: Room, livekit: ApiConfig['livekit']) => {
|
||||
await room.prepareConnection(livekit.url)
|
||||
|
||||
if (isFireFox() && livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
livekit.url.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered insecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getConnectionEndReason = (
|
||||
reason: DisconnectReason | undefined,
|
||||
hasConnected: boolean
|
||||
) => {
|
||||
switch (reason) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
return undefined
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
return reason
|
||||
default:
|
||||
// Reconnecting gave up, or the join itself failed
|
||||
return hasConnected
|
||||
? ConnectionEndReason.Lost
|
||||
: ConnectionEndReason.Failed
|
||||
}
|
||||
}
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
initialRoomData,
|
||||
mode = 'join',
|
||||
onLobbyRequired,
|
||||
}: {
|
||||
roomId: string
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
onLobbyRequired: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
userChoices: LocalUserChoices
|
||||
}
|
||||
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
status: createStatus,
|
||||
isError: isCreateError,
|
||||
} = useCreateRoom({
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(fetchKey, data)
|
||||
},
|
||||
})
|
||||
const [hasJoined, setHasJoined] = useState(false)
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
const hasConnectedRef = useRef(false)
|
||||
|
||||
const { mutateAsync: createRoom, isError: isCreateError } = useCreateRoom()
|
||||
const {
|
||||
status: fetchStatus,
|
||||
isError: isFetchError,
|
||||
error: fetchError,
|
||||
data,
|
||||
data: createRoomData,
|
||||
} = useQuery({
|
||||
queryKey: fetchKey,
|
||||
queryKey: [keys.room, roomId],
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
initialData: initialRoomData,
|
||||
queryFn: () =>
|
||||
fetchRoom({
|
||||
roomId: roomId as string,
|
||||
username: username,
|
||||
}).catch((error) => {
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({ slug: roomId, username })
|
||||
}
|
||||
// A deleted room can't be recreated, surface the error instead
|
||||
if (error.statusCode == '410') throw error
|
||||
joinRoom({ roomId, username }).catch(async (error) => {
|
||||
if (error.statusCode != 404) throw error
|
||||
await createRoom({ slug: roomId })
|
||||
return joinRoom({ roomId, username })
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isLobbyRequired =
|
||||
!!createRoomData && !createRoomData.livekit && !hasJoined
|
||||
const status =
|
||||
createRoomData?.livekit || hasJoined
|
||||
? 'success'
|
||||
: isLobbyRequired
|
||||
? 'pending'
|
||||
: fetchStatus
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: true,
|
||||
@@ -155,62 +207,11 @@ export const Conference = ({
|
||||
apiConfig?.livekit.default_video_codec,
|
||||
])
|
||||
|
||||
const reconnect = apiConfig?.livekit.reconnect
|
||||
const room = useMemo(
|
||||
() => createLiveKitRoom(roomOptions, reconnect),
|
||||
[roomOptions, reconnect]
|
||||
() => createLiveKitRoom(roomOptions, apiConfig?.livekit.reconnect),
|
||||
[roomOptions, apiConfig?.livekit.reconnect]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered insecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
const hasConnectedRef = useRef(false)
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
@@ -225,7 +226,61 @@ export const Conference = ({
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
const handleConnected = useCallback(async () => {
|
||||
hasConnectedRef.current = true
|
||||
if (!apiConfig) return
|
||||
if (
|
||||
userPreferencesSnap.is_auto_mute_large_room_enabled &&
|
||||
!hasAutoMutedRef.current &&
|
||||
userConfig.audioEnabled &&
|
||||
room.numParticipants > apiConfig.auto_mute_on_join_threshold
|
||||
) {
|
||||
hasAutoMutedRef.current = true
|
||||
await room.localParticipant.setMicrophoneEnabled(false)
|
||||
notifyAutoMutedOnJoin()
|
||||
}
|
||||
}, [
|
||||
apiConfig,
|
||||
room,
|
||||
userConfig.audioEnabled,
|
||||
userPreferencesSnap.is_auto_mute_large_room_enabled,
|
||||
])
|
||||
|
||||
const handleDisconnected = useCallback(
|
||||
(reason?: DisconnectReason) => {
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: {
|
||||
reason: getConnectionEndReason(reason, hasConnectedRef.current),
|
||||
room_id: roomId,
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[roomId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (createRoomData?.livekit) setHasJoined(true)
|
||||
}, [createRoomData?.livekit])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLobbyRequired) onLobbyRequired()
|
||||
}, [isLobbyRequired, onLobbyRequired])
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
warmUpConnection(room, apiConfig.livekit).then(() =>
|
||||
setIsConnectionWarmedUp(true)
|
||||
)
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
if (fetchError?.statusCode == 410) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
@@ -245,20 +300,13 @@ export const Conference = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||
<QueryAware status={status}>
|
||||
<Screen header={false} footer={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
token={createRoomData?.livekit?.token}
|
||||
connect={isConnectionWarmedUp}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
@@ -273,65 +321,8 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={handleError}
|
||||
onConnected={async () => {
|
||||
hasConnectedRef.current = true
|
||||
if (!apiConfig) return
|
||||
if (
|
||||
userPreferencesSnap.is_auto_mute_large_room_enabled &&
|
||||
!hasAutoMutedRef.current &&
|
||||
userConfig.audioEnabled &&
|
||||
room.numParticipants > apiConfig.auto_mute_on_join_threshold
|
||||
) {
|
||||
hasAutoMutedRef.current = true
|
||||
await room.localParticipant.setMicrophoneEnabled(false)
|
||||
notifyAutoMutedOnJoin()
|
||||
}
|
||||
}}
|
||||
onDisconnected={(e) => {
|
||||
const metadata = {
|
||||
room_id: roomId,
|
||||
}
|
||||
|
||||
switch (e) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: { ...metadata },
|
||||
}
|
||||
)
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: {
|
||||
reason: e,
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
default:
|
||||
// Reconnecting gave up, or the join itself failed
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: {
|
||||
reason: hasConnectedRef.current
|
||||
? ConnectionEndReason.Lost
|
||||
: ConnectionEndReason.Failed,
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
onConnected={handleConnected}
|
||||
onDisconnected={handleDisconnected}
|
||||
>
|
||||
<WatchMediaDeviceErrors />
|
||||
<VideoConference />
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
RiFileCopyLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
|
||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
|
||||
|
||||
// fixme - extract in a proper primitive this dialog without overlay
|
||||
const StyledRACDialog = styled(Dialog, {
|
||||
@@ -64,9 +67,20 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(roomData)
|
||||
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
|
||||
const dismiss = () => {
|
||||
// The dialog opens on its own when joining, so there is no trigger to
|
||||
// restore focus to: move it to the control bar instead of body.
|
||||
getFirstControlBarFocusable(CONTROL_BAR_REGION_ID)?.focus()
|
||||
setIsDismissed(true)
|
||||
}
|
||||
|
||||
useEscapeToClose(showInviteDialog, dialogRef, dismiss)
|
||||
|
||||
if (!showInviteDialog) return null
|
||||
return (
|
||||
<StyledRACDialog>
|
||||
<StyledRACDialog ref={dialogRef}>
|
||||
<VStack
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
@@ -81,9 +95,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
onPress={dismiss}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
@@ -10,15 +10,40 @@ import { Form, Text } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { saveUsername, userStore } from '@/stores/user'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
|
||||
import { joinRoom, type JoinRoomParams } from '../api/joinRoom'
|
||||
import { ApiAccessLevel, type ApiRoom } from '../api/ApiRoom'
|
||||
import { ApiLobbyStatus } from '../api/requestEntry'
|
||||
import { useLobby } from '../hooks/useLobby'
|
||||
|
||||
const LobbyMessage = ({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
body: string
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{title}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={children ? css({ marginBottom: '1.5rem' }) : undefined}
|
||||
>
|
||||
{body}
|
||||
</Text>
|
||||
{children}
|
||||
</VStack>
|
||||
)
|
||||
|
||||
export const Lobby = ({
|
||||
roomId,
|
||||
enterRoom,
|
||||
@@ -27,124 +52,92 @@ export const Lobby = ({
|
||||
enterRoom: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { data: configData } = useConfig()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const { username } = useSnapshot(userStore)
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
const displayName = username || user?.full_name
|
||||
|
||||
// Join strategy:
|
||||
// 1. Submitting the form asks to join the room, which checks access
|
||||
// 2. The response is written to the [keys.room] cache entry, which
|
||||
// Conference reuses without refetching. It holds the LiveKit credentials
|
||||
// when the user may bypass the lobby
|
||||
// 3. Otherwise the user waits in the lobby, and the credentials returned
|
||||
// once accepted are added to it
|
||||
const {
|
||||
mutate: join,
|
||||
data: roomData,
|
||||
error,
|
||||
isError,
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isError && error?.statusCode == 404) {
|
||||
} = useMutation<ApiRoom, ApiError, JoinRoomParams>({
|
||||
mutationFn: joinRoom,
|
||||
onSuccess: (room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
},
|
||||
onError: ({ statusCode }) => {
|
||||
// The room component will handle the room creation if the user is authenticated
|
||||
enterRoom()
|
||||
}
|
||||
}, [isError, error, enterRoom])
|
||||
|
||||
const handleAccepted = (response: ApiRequestEntry) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
}
|
||||
if (statusCode == 404) enterRoom()
|
||||
},
|
||||
})
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username: username || user?.full_name || 'anonymous',
|
||||
onAccepted: handleAccepted,
|
||||
username: displayName || 'anonymous',
|
||||
onAccepted: (response) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
},
|
||||
})
|
||||
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data, error } = await refetchRoom()
|
||||
|
||||
if (error?.statusCode == 410) return
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
return
|
||||
}
|
||||
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const isRoomDeleted = isError && error?.statusCode == 410
|
||||
const lobbyStatus = isRoomDeleted ? ApiLobbyStatus.DELETED : status
|
||||
const hasJoinFailed = isError && !isRoomDeleted && error?.statusCode != 404
|
||||
|
||||
const handleSubmit = () => {
|
||||
join(
|
||||
{ roomId, username: displayName || undefined },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.livekit) {
|
||||
enterRoom()
|
||||
return
|
||||
}
|
||||
if (data.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
switch (lobbyStatus) {
|
||||
case ApiLobbyStatus.DELETED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('deleted.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('deleted.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<LobbyMessage title={t('deleted.title')} body={t('deleted.body')} />
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('timeoutInvite.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('timeoutInvite.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<LobbyMessage
|
||||
title={t('timeoutInvite.title')}
|
||||
body={t('timeoutInvite.body')}
|
||||
/>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.DENIED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('denied.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('denied.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
return <LobbyMessage title={t('denied.title')} body={t('denied.body')} />
|
||||
|
||||
case ApiLobbyStatus.WAITING:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('waiting.title')}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={css({ marginBottom: '1.5rem' })}
|
||||
>
|
||||
{t('waiting.body')}
|
||||
</Text>
|
||||
<LobbyMessage title={t('waiting.title')} body={t('waiting.body')}>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
</LobbyMessage>
|
||||
)
|
||||
|
||||
default:
|
||||
@@ -168,7 +161,7 @@ export const Lobby = ({
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username || user?.full_name}
|
||||
defaultValue={displayName}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
@@ -178,6 +171,11 @@ export const Lobby = ({
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
{hasJoinFailed && (
|
||||
<Text variant="warning" role="alert" margin={false} centered>
|
||||
{t('errors.joinFailed')}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -3,13 +3,11 @@ import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useEffect } from 'react'
|
||||
import { closeSidePanel } from '@/stores/layout'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
@@ -34,12 +32,10 @@ export const Admin = () => {
|
||||
}
|
||||
}, [isAdminOrOwner])
|
||||
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
// Read the cached room only: an observer with its own queryFn could serve
|
||||
// the refetch that follows a setting change with a GET, which returns no
|
||||
// LiveKit credentials
|
||||
const readOnlyData = useRoomData()
|
||||
|
||||
const {
|
||||
toggleMicrophone,
|
||||
|
||||
@@ -32,10 +32,12 @@ const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
|
||||
// Set by the conference when the user turns out to have to go through the
|
||||
// lobby, even when the join screen was skipped
|
||||
const [isLobbyRequired, setIsLobbyRequired] = useState(false)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const [location, setLocation] = useLocation()
|
||||
const initialRoomData = history.state?.initialRoomData
|
||||
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
|
||||
const skipJoinScreen = isLoggedIn && mode === 'create'
|
||||
|
||||
@@ -75,10 +77,16 @@ const Room = () => {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||
if (isLobbyRequired || (!hasSubmittedEntry && !skipJoinScreen)) {
|
||||
return (
|
||||
<BaseRoom>
|
||||
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||
<Join
|
||||
enterRoom={() => {
|
||||
setIsLobbyRequired(false)
|
||||
setHasSubmittedEntry(true)
|
||||
}}
|
||||
roomId={roomId}
|
||||
/>
|
||||
</BaseRoom>
|
||||
)
|
||||
}
|
||||
@@ -86,9 +94,10 @@ const Room = () => {
|
||||
return (
|
||||
<BaseRoom>
|
||||
<Conference
|
||||
initialRoomData={initialRoomData}
|
||||
key={roomId}
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
onLobbyRequired={() => setIsLobbyRequired(true)}
|
||||
/>
|
||||
</BaseRoom>
|
||||
)
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Wird anderen Teilnehmenden angezeigt",
|
||||
"usernameLabel": "Dein Name",
|
||||
"errors": {
|
||||
"joinFailed": "Beitritt zum Meeting nicht möglich. Bitte versuche es erneut.",
|
||||
"usernameEmpty": "Dein Name darf nicht leer sein"
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name",
|
||||
"errors": {
|
||||
"joinFailed": "Unable to join the meeting. Please try again.",
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Se muestra a los demás participantes",
|
||||
"usernameLabel": "Tu nombre",
|
||||
"errors": {
|
||||
"joinFailed": "No se ha podido acceder a la reunión. Inténtalo de nuevo.",
|
||||
"usernameEmpty": "Tu nombre no puede estar vacío"
|
||||
},
|
||||
"cameraDisabled": "La cámara está desactivada.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom",
|
||||
"errors": {
|
||||
"joinFailed": "Impossible de rejoindre la réunion. Veuillez réessayer.",
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Getoond aan andere deelnemers",
|
||||
"usernameLabel": "Uw naam",
|
||||
"errors": {
|
||||
"joinFailed": "Deelnemen aan de vergadering is niet gelukt. Probeer het opnieuw.",
|
||||
"usernameEmpty": "Uw naam kan niet leeg zijn"
|
||||
},
|
||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary.core.analytics import get_analytics
|
||||
from summary.core.celery_worker import (
|
||||
@@ -13,7 +14,9 @@ from summary.core.celery_worker import (
|
||||
summarize_v2_task,
|
||||
)
|
||||
from summary.core.config import AuthorizedTenant, get_settings
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.security import verify_tenant_api_key_v2
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookFailurePayload,
|
||||
@@ -52,7 +55,7 @@ async def create_transcribe_task_v2(
|
||||
task = process_audio_transcribe_v2_task.apply_async(
|
||||
args=[
|
||||
{
|
||||
**request.model_dump(),
|
||||
**dump_for_transport(request),
|
||||
"tenant_id": request_tenant.id,
|
||||
"received_at": datetime.now(timezone.utc),
|
||||
}
|
||||
@@ -125,7 +128,11 @@ async def get_transcribe_job_status(
|
||||
|
||||
if task.status == "SUCCESS":
|
||||
result = task.result
|
||||
return TranscribeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||
try:
|
||||
payload = TranscribeWebhookSuccessPayload.model_validate(result)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
return dump_for_transport(payload)
|
||||
|
||||
if task.status == "FAILURE":
|
||||
return TranscribeWebhookFailurePayload(
|
||||
@@ -155,7 +162,11 @@ async def get_summarize_job_status(
|
||||
|
||||
if task.status == "SUCCESS":
|
||||
result = task.result
|
||||
return SummarizeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||
try:
|
||||
payload = SummarizeWebhookSuccessPayload.model_validate(result)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
return dump_for_transport(payload)
|
||||
|
||||
if task.status == "FAILURE":
|
||||
return SummarizeWebhookFailurePayload(
|
||||
|
||||
@@ -5,7 +5,6 @@ import socket
|
||||
import time
|
||||
from collections import Counter
|
||||
from functools import cached_property, lru_cache
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import redis
|
||||
from celery.utils.log import get_task_logger
|
||||
@@ -13,6 +12,7 @@ from posthog import Posthog
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
|
||||
from summary.core.privacy import redact_url
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
@@ -54,8 +54,8 @@ class Analytics:
|
||||
self._client.capture(
|
||||
event_name, distinct_id=distinct_id, properties=properties
|
||||
)
|
||||
except Exception as e:
|
||||
raise AnalyticsException("Failed to capture analytics event") from e
|
||||
except Exception:
|
||||
raise AnalyticsException("Failed to capture analytics event") from None
|
||||
|
||||
def is_feature_enabled(self, feature_name: str, distinct_id: str = None) -> bool:
|
||||
"""Check if a feature flag is enabled for a user."""
|
||||
@@ -64,8 +64,8 @@ class Analytics:
|
||||
|
||||
try:
|
||||
return self._client.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e:
|
||||
logger.error("Error checking feature flag %s: %s", feature_name, e)
|
||||
except Exception:
|
||||
logger.error("Error checking feature flag %s", feature_name)
|
||||
return False
|
||||
|
||||
|
||||
@@ -133,8 +133,7 @@ class MetadataManager:
|
||||
}
|
||||
|
||||
if isinstance(task_payload, TranscribeTaskJob):
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
clean_url = redact_url(task_payload.cloud_storage_url.get_secret_value())
|
||||
initial_metadata["source_url"] = clean_url
|
||||
initial_metadata["asr_model"] = settings.whisperx_asr_model
|
||||
elif isinstance(task_payload, SummarizeTaskJob):
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from celery import Celery, signals
|
||||
from celery import Celery, Task, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from openai.types.audio import Transcription
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from requests import exceptions
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.docs_service import create_document_in_lasuite_docs
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
from summary.core.file_service import (
|
||||
CorruptedAudioFile,
|
||||
FileService,
|
||||
@@ -31,6 +34,7 @@ from summary.core.models import (
|
||||
SummarizeTaskJob,
|
||||
TranscribeTaskJob,
|
||||
)
|
||||
from summary.core.privacy import dump_for_transport, redact_url
|
||||
from summary.core.prompt import (
|
||||
FORMAT_NEXT_STEPS,
|
||||
FORMAT_PLAN,
|
||||
@@ -63,8 +67,24 @@ metadata_manager = MetadataManager()
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _reveal(secret: SecretStr | None) -> str | None:
|
||||
"""Return the underlying value of an optional secret."""
|
||||
return secret.get_secret_value() if secret is not None else None
|
||||
|
||||
|
||||
class PrivateTask(Task):
|
||||
"""Hide task arguments in Celery logs and monitoring events."""
|
||||
|
||||
def apply_async(self, args=None, kwargs=None, **options):
|
||||
"""Publish with redacted display representations."""
|
||||
options.update(argsrepr="[Filtered]", kwargsrepr="[Filtered]")
|
||||
return super().apply_async(args=args, kwargs=kwargs, **options)
|
||||
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
task_cls=PrivateTask,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
@@ -76,10 +96,28 @@ celery = Celery(
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
|
||||
class CeleryLogPrivacyFilter(logging.Filter):
|
||||
"""Prevent Celery trace logs from serializing task results or exceptions."""
|
||||
|
||||
def filter(self, record):
|
||||
"""Keep task identity and state without upstream values."""
|
||||
if record.levelno < logging.INFO:
|
||||
return True
|
||||
data = getattr(record, "data", None)
|
||||
if isinstance(data, dict):
|
||||
record.msg = "Task %s[%s] finished or retried (details omitted)"
|
||||
record.args = (data.get("name", "unknown"), data.get("id", "unknown"))
|
||||
record.data = {}
|
||||
record.exc_info = None
|
||||
record.exc_text = None
|
||||
return True
|
||||
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_celery_sentry(**_kwargs):
|
||||
"""Initialize Sentry in the Celery worker."""
|
||||
init_sentry()
|
||||
logging.getLogger("celery.app.trace").addFilter(CeleryLogPrivacyFilter())
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
@@ -162,9 +200,9 @@ def transcribe_audio(
|
||||
try:
|
||||
res.raise_for_status()
|
||||
except requests.exceptions.HTTPError:
|
||||
logger.exception("WhisperX transcription failed")
|
||||
logger.error("WhisperX transcription failed")
|
||||
# We reraise the error so that it can be retried by celery
|
||||
raise
|
||||
raise requests.exceptions.HTTPError("WhisperX request failed") from None
|
||||
|
||||
transcription_json: dict[str, Any] = res.json()
|
||||
# We remove the "usage" key from the transcription_json dictionary
|
||||
@@ -175,13 +213,16 @@ def transcribe_audio(
|
||||
# We force the use of the Transcription model here
|
||||
# to avoid changing too much code for now.
|
||||
# Note that it should be WhisperXResponse instead.
|
||||
transcription = Transcription.model_validate(
|
||||
# We add a dummy "text" to make the model validate,
|
||||
# Some API responses lack the "text" key.
|
||||
{"text": "", **transcription_json},
|
||||
extra="allow",
|
||||
strict=False,
|
||||
)
|
||||
try:
|
||||
transcription = Transcription.model_validate(
|
||||
# We add a dummy "text" to make the model validate,
|
||||
# Some API responses lack the "text" key.
|
||||
{"text": "", **transcription_json},
|
||||
extra="allow",
|
||||
strict=False,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
|
||||
# Logging
|
||||
transcription_duration = round(time.time() - transcription_start_time, 2)
|
||||
@@ -199,9 +240,9 @@ def transcribe_audio(
|
||||
if raises:
|
||||
raise e
|
||||
redacted_cloud_storage_url = (
|
||||
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||
redact_url(cloud_storage_url) if cloud_storage_url else None
|
||||
)
|
||||
logger.exception(
|
||||
logger.error(
|
||||
("Unexpected error while preparing file %s "),
|
||||
redacted_cloud_storage_url,
|
||||
)
|
||||
@@ -230,7 +271,7 @@ def resolve_speaker_identities_and_apply_to(
|
||||
logger.debug("Running resolve_speaker_identities")
|
||||
try:
|
||||
metadata = file_service.read_cloud_storage_json(
|
||||
recording_metadata.cloud_storage_url
|
||||
recording_metadata.cloud_storage_url.get_secret_value()
|
||||
)
|
||||
speaker_mapping = resolve_speaker_identities(
|
||||
metadata,
|
||||
@@ -241,21 +282,18 @@ def resolve_speaker_identities_and_apply_to(
|
||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||
return WhisperXResponse.model_validate(new_transcription)
|
||||
|
||||
except FileServiceException as exc:
|
||||
except FileServiceException:
|
||||
logger.error(
|
||||
"Error reading metadata for task %s; skipping speaker assignment."
|
||||
" Error: %s",
|
||||
"Error reading metadata for task %s; skipping speaker assignment.",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
except Exception:
|
||||
logger.error(
|
||||
"resolve_speaker_identities failed for task %s; skipping"
|
||||
" speaker assignment. Error: %s",
|
||||
" speaker assignment.",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
@@ -302,6 +340,17 @@ def format_actions(llm_output: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_llm_json(value: str) -> dict:
|
||||
"""Parse an LLM response without exposing it in decoding errors."""
|
||||
try:
|
||||
result = json.loads(value)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
raise LLMException("Invalid LLM response") from None
|
||||
|
||||
|
||||
def summarize_transcription_internals(
|
||||
*, distinct_id: str, transcript: str, session_id: str
|
||||
) -> str:
|
||||
@@ -342,13 +391,13 @@ def summarize_transcription_internals(
|
||||
)
|
||||
logger.info("Plan generated")
|
||||
|
||||
res = json.loads(parts)
|
||||
res = parse_llm_json(parts)
|
||||
parts = res.get("titles", [])
|
||||
logger.info("Parts to summarize: %s", parts)
|
||||
logger.info("Parts to summarize: %s", len(parts))
|
||||
parts_summarized = []
|
||||
for part in parts:
|
||||
for i, part in enumerate(parts):
|
||||
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
|
||||
logger.info("Summarizing part: %s", part)
|
||||
logger.info("Summarizing part %s", i + 1)
|
||||
parts_summarized.append(
|
||||
llm_service.call(PROMPT_SYSTEM_PART, prompt_user_part, name="part")
|
||||
)
|
||||
@@ -364,7 +413,7 @@ def summarize_transcription_internals(
|
||||
response_format=FORMAT_NEXT_STEPS,
|
||||
)
|
||||
|
||||
next_steps = format_actions(json.loads(next_steps))
|
||||
next_steps = format_actions(parse_llm_json(next_steps))
|
||||
|
||||
logger.info("Next steps generated")
|
||||
|
||||
@@ -437,9 +486,11 @@ def call_webhook_v2_task(
|
||||
tenant_id: str,
|
||||
):
|
||||
"""Calls a webhook asynchrously (retry handled by celery)."""
|
||||
call_webhook_v2(
|
||||
payload=webhook_payload_adapter.validate_python(payload), tenant_id=tenant_id
|
||||
)
|
||||
try:
|
||||
validated_payload = webhook_payload_adapter.validate_python(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
call_webhook_v2(payload=validated_payload, tenant_id=tenant_id)
|
||||
|
||||
|
||||
@celery.task(
|
||||
@@ -465,7 +516,10 @@ def process_audio_transcribe_v2_task(
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||
"""
|
||||
payload = TranscribeTaskJob.model_validate(payload)
|
||||
try:
|
||||
payload = TranscribeTaskJob.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
logger.info(
|
||||
"Transcribing for object received | Owner: %s",
|
||||
payload.user_sub,
|
||||
@@ -477,7 +531,7 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_res = WhisperXResponse(
|
||||
**transcribe_audio( # type: ignore
|
||||
task_id=job_id,
|
||||
cloud_storage_url=payload.cloud_storage_url,
|
||||
cloud_storage_url=payload.cloud_storage_url.get_secret_value(),
|
||||
language=payload.language,
|
||||
raises=True,
|
||||
).model_dump()
|
||||
@@ -514,8 +568,8 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_res.model_dump(),
|
||||
payload.context_language,
|
||||
payload.language,
|
||||
payload.push_to_docs_config.download_link,
|
||||
payload.push_to_docs_config.form_link,
|
||||
_reveal(payload.push_to_docs_config.download_link),
|
||||
_reveal(payload.push_to_docs_config.form_link),
|
||||
)
|
||||
|
||||
create_document_in_lasuite_docs(
|
||||
@@ -556,18 +610,22 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_data_url=file_service.get_transcript_signed_url(job_id),
|
||||
)
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
args=[dump_for_transport(success_payload), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_transcript_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
return dump_for_transport(success_payload)
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||
try:
|
||||
payload = TranscribeTaskJob.model_validate(args[0])
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
metadata_manager.create(task_id, payload)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||
@@ -627,7 +685,10 @@ def summarize_v2_task(
|
||||
1. Run summary internals
|
||||
2. Sends the final summary via webhook.
|
||||
"""
|
||||
payload = SummarizeTaskJob.model_validate(payload)
|
||||
try:
|
||||
payload = SummarizeTaskJob.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
summary = summarize_transcription_internals(
|
||||
distinct_id=payload.user_sub,
|
||||
transcript=payload.content,
|
||||
@@ -652,18 +713,22 @@ def summarize_v2_task(
|
||||
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||
)
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
args=[dump_for_transport(success_payload), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_summary_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
return dump_for_transport(success_payload)
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=summarize_v2_task)
|
||||
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
|
||||
try:
|
||||
payload = SummarizeTaskJob.model_validate(args[0])
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
metadata_manager.create(task_id, payload)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=summarize_v2_task)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from requests import Session
|
||||
from requests import RequestException, Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
@@ -43,6 +43,8 @@ def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||
response = session.post(url, json=data, timeout=(20, 3 * 60))
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except RequestException:
|
||||
raise RequestException("Docs delivery failed") from None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Validation errors suitable for logging and transport."""
|
||||
|
||||
from typing import get_args
|
||||
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic_core import ErrorType, PydanticCustomError
|
||||
|
||||
_BUILTIN_ERROR_TYPES = frozenset(get_args(ErrorType))
|
||||
|
||||
|
||||
def clean_validation_errors(errors: list[dict]) -> list[dict]:
|
||||
"""Mask SecretStr values, including those nested in validation input."""
|
||||
|
||||
def clean(value):
|
||||
if isinstance(value, SecretStr):
|
||||
return "[redacted]"
|
||||
if isinstance(value, Exception):
|
||||
# Do not retain validator exception objects, whose args may hold secrets.
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: clean(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [clean(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(clean(item) for item in value)
|
||||
return value
|
||||
|
||||
return clean(errors)
|
||||
|
||||
|
||||
class CleanedValidationError(ValidationError):
|
||||
"""A new Pydantic exception with secret inputs replaced by a marker."""
|
||||
|
||||
def __init__(self, error, *args, secret_input=False, **kwargs):
|
||||
"""State is initialized by Pydantic during __new__."""
|
||||
|
||||
def __new__(cls, error, *args, secret_input=False, **kwargs):
|
||||
"""Accept an original error, or Pydantic's native construction arguments."""
|
||||
if not isinstance(error, ValidationError):
|
||||
# Pydantic uses this path when constructing or unpickling the error.
|
||||
return super().__new__(cls, error, *args, **kwargs)
|
||||
|
||||
details = clean_validation_errors(error.errors(include_url=False))
|
||||
for detail in details:
|
||||
if secret_input:
|
||||
# An adapter may have received the unwrapped value of a SecretStr.
|
||||
detail["input"] = "[redacted]"
|
||||
return cls._from_details(error.title, details)
|
||||
|
||||
@classmethod
|
||||
def _from_details(cls, title, details):
|
||||
"""Reconstruct builtin and custom Pydantic error codes."""
|
||||
for detail in details:
|
||||
if detail["type"] not in _BUILTIN_ERROR_TYPES:
|
||||
detail["type"] = PydanticCustomError(
|
||||
detail["type"], detail["msg"], detail.get("ctx")
|
||||
)
|
||||
return cls.from_exception_data(title, details, hide_input=True)
|
||||
|
||||
def __reduce__(self):
|
||||
"""Preserve custom error codes when Celery pickles the exception."""
|
||||
return self._from_details, (self.title, self.errors(include_url=False))
|
||||
@@ -17,6 +17,7 @@ import requests
|
||||
from botocore.config import Config
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import redact_url
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
settings = get_settings()
|
||||
@@ -314,7 +315,7 @@ class FileService:
|
||||
"""Download file from a cloud storage URL to local temporary file."""
|
||||
logger.info(
|
||||
"Download recording from URL | cloud_storage_url: %s",
|
||||
cloud_storage_url,
|
||||
redact_url(cloud_storage_url),
|
||||
)
|
||||
|
||||
if not cloud_storage_url:
|
||||
@@ -352,10 +353,10 @@ class FileService:
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
return local_path
|
||||
|
||||
except requests.RequestException as e:
|
||||
except requests.RequestException:
|
||||
raise FileServiceException(
|
||||
"Unexpected error while downloading object from cloud_storage_url."
|
||||
) from e
|
||||
) from None
|
||||
|
||||
def _validate_duration(self, duration_seconds: float) -> None:
|
||||
"""Validate audio file duration against configured maximum."""
|
||||
@@ -376,7 +377,7 @@ class FileService:
|
||||
|
||||
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
||||
"""Read and parse a JSON file from a url."""
|
||||
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||
logger.info("Reading JSON: %s", redact_url(cloud_storage_url))
|
||||
res = requests.get(cloud_storage_url, timeout=(10, 100))
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
@@ -140,6 +140,6 @@ class LLMService:
|
||||
response = self._client.chat.completions.create(**params)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
except Exception:
|
||||
logger.error("LLM call failed")
|
||||
raise LLMException("LLM call failed") from None
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field, field_validator
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
EmailStr,
|
||||
Field,
|
||||
field_validator,
|
||||
)
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.types import Url
|
||||
from summary.core.types import SecretUrl
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -13,6 +20,8 @@ settings = get_settings()
|
||||
class SharedV2TaskCreation(BaseModel):
|
||||
"""Model that holds basic information for task creation."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||
user_email: EmailStr | None = Field(
|
||||
default=None,
|
||||
@@ -24,7 +33,9 @@ class SharedV2TaskCreation(BaseModel):
|
||||
class RecordingMetadata(BaseModel):
|
||||
"""Model for recording metadata."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
cloud_storage_url: SecretUrl = Field(
|
||||
title="Cloud Storage URL",
|
||||
description="The URL of the metadata file for speaker assignment.",
|
||||
)
|
||||
@@ -35,6 +46,8 @@ class RecordingMetadata(BaseModel):
|
||||
class PushToDocsBaseConfig(BaseModel):
|
||||
"""Model containing information for pushing transcript and summaries to docs."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
user_email: EmailStr = Field(
|
||||
title="User Email", description="The user's email, future owner of the docs."
|
||||
)
|
||||
@@ -44,12 +57,12 @@ class PushToDocsBaseConfig(BaseModel):
|
||||
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
|
||||
"""Model for push to docs information for transcripts."""
|
||||
|
||||
download_link: Url | None = Field(
|
||||
download_link: SecretUrl | None = Field(
|
||||
default=None,
|
||||
title="Download Link",
|
||||
description="The link to download the recording.",
|
||||
)
|
||||
form_link: Url | None = Field(
|
||||
form_link: SecretUrl | None = Field(
|
||||
default=None,
|
||||
title="Form Link",
|
||||
description="The link to fill out a form for the recording.",
|
||||
@@ -69,7 +82,7 @@ class PushToDocsSummaryConfig(PushToDocsBaseConfig):
|
||||
class TranscribeTaskApiRequest(SharedV2TaskCreation):
|
||||
"""Model for creating a transcribe task (used for API request)."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
cloud_storage_url: SecretUrl = Field(
|
||||
title="Cloud storage URL",
|
||||
description="The URL of the audio file to transcribe.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Privacy helpers for observability."""
|
||||
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
|
||||
def dump_for_transport(model: BaseModel) -> dict:
|
||||
"""Reveal secrets explicitly for task messages, HTTP bodies and API responses."""
|
||||
|
||||
def reveal(value):
|
||||
if isinstance(value, SecretStr):
|
||||
return value.get_secret_value()
|
||||
if isinstance(value, dict):
|
||||
return {key: reveal(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [reveal(item) for item in value]
|
||||
return value
|
||||
|
||||
return reveal(model.model_dump())
|
||||
|
||||
|
||||
def redact_url(url):
|
||||
"""Keep the source identifiable without logging query or user credentials."""
|
||||
if not isinstance(url, str):
|
||||
return "[redacted]"
|
||||
# Remove query and fragment before parsing, including for malformed URLs.
|
||||
base, has_query, _ = url.partition("?")
|
||||
base = base.partition("#")[0]
|
||||
try:
|
||||
parts = urlsplit(base)
|
||||
except ValueError:
|
||||
return "[redacted]"
|
||||
authority = parts.netloc.rsplit("@", 1)[-1]
|
||||
redacted = urlunsplit(parts._replace(netloc=authority))
|
||||
return redacted + ("?[redacted]" if has_query else "")
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Sentry configuration."""
|
||||
|
||||
import re
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import redact_url
|
||||
|
||||
# Exact names (case-insensitive) of variables and dict keys to redact.
|
||||
SENSITIVE_DATA_DENYLIST = [
|
||||
@@ -90,5 +93,45 @@ def init_sentry() -> None:
|
||||
# Task creation requests carry the content to summarize.
|
||||
max_request_body_size="never",
|
||||
include_local_variables=True,
|
||||
before_send=before_send,
|
||||
before_send_transaction=before_send,
|
||||
before_breadcrumb=before_breadcrumb,
|
||||
event_scrubber=build_event_scrubber(),
|
||||
)
|
||||
|
||||
|
||||
def _scrub_strings(value):
|
||||
"""Remove URL credentials, queries and fragments throughout an event."""
|
||||
if isinstance(value, str):
|
||||
return re.sub(
|
||||
r"https?://[^\s\"'<>]+",
|
||||
lambda m: redact_url(m.group()),
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return {key: _scrub_strings(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_scrub_strings(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def before_send(event, hint):
|
||||
"""Keep exception types and stacks, but discard untrusted message text."""
|
||||
del hint
|
||||
event.pop("message", None)
|
||||
event.pop("logentry", None)
|
||||
request = event.get("request", {})
|
||||
request.pop("data", None)
|
||||
request.pop("query_string", None)
|
||||
for exception in event.get("exception", {}).get("values", []):
|
||||
exception["value"] = "[Filtered]"
|
||||
return _scrub_strings(event)
|
||||
|
||||
|
||||
def before_breadcrumb(crumb, hint):
|
||||
"""Drop arbitrary breadcrumb text and scrub URLs in structured data."""
|
||||
del hint
|
||||
crumb.pop("message", None)
|
||||
crumb.pop("data", None)
|
||||
return _scrub_strings(crumb)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
||||
|
||||
from summary.core.types import SecretUrl
|
||||
|
||||
|
||||
class WordSegment(BaseModel):
|
||||
@@ -65,6 +67,8 @@ class WhisperXResponse(BaseModel):
|
||||
class BaseWebhook(BaseModel):
|
||||
"""Base webhook payload."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
job_id: str = Field(
|
||||
title="Job ID",
|
||||
description="The ID of the job document in the receiver system.",
|
||||
@@ -76,7 +80,7 @@ class TranscribeWebhookSuccessPayload(BaseWebhook):
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
transcription_data_url: str = Field(
|
||||
transcription_data_url: SecretUrl = Field(
|
||||
title="Transcript", description="URL to the raw transcription data."
|
||||
)
|
||||
|
||||
@@ -121,7 +125,7 @@ class SummarizeWebhookSuccessPayload(BaseWebhook):
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
summary_data_url: str = Field(
|
||||
summary_data_url: SecretUrl = Field(
|
||||
title="Summary", description="URL to the raw summary data."
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BeforeValidator,
|
||||
HttpUrl,
|
||||
SecretStr,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
http_url_adapter = TypeAdapter(HttpUrl)
|
||||
Url = Annotated[
|
||||
str, BeforeValidator(lambda value: str(http_url_adapter.validate_python(value)))
|
||||
]
|
||||
|
||||
|
||||
def _validate_secret_url(value: SecretStr) -> SecretStr:
|
||||
"""Validate and normalize a URL without echoing it in validator messages."""
|
||||
try:
|
||||
return SecretStr(
|
||||
str(http_url_adapter.validate_python(value.get_secret_value()))
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
|
||||
|
||||
SecretUrl = Annotated[SecretStr, AfterValidator(_validate_secret_url)]
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import requests
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.shared_models import (
|
||||
WebhookPayloads,
|
||||
)
|
||||
@@ -29,16 +30,19 @@ def call_webhook_v2(
|
||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||
logger.debug("Request payload: %s", payload.model_dump_json(indent=2))
|
||||
|
||||
response = requests.post(
|
||||
tenant.webhook_url,
|
||||
json=payload.model_dump(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||
"User-Agent": settings.app_external_user_agent,
|
||||
},
|
||||
timeout=(10, 20),
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.post(
|
||||
tenant.webhook_url,
|
||||
json=dump_for_transport(payload),
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||
"User-Agent": settings.app_external_user_agent,
|
||||
},
|
||||
timeout=(10, 20),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException:
|
||||
raise requests.RequestException("Webhook delivery failed") from None
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Application."""
|
||||
|
||||
from dockerflow.fastapi import router as dockerflow_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.exceptions import clean_validation_errors
|
||||
from summary.core.sentry import init_sentry
|
||||
|
||||
settings = get_settings()
|
||||
@@ -19,3 +23,13 @@ app = FastAPI(
|
||||
|
||||
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||
app.include_router(dockerflow_router)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
||||
"""Keep validation details while masking SecretStr input values."""
|
||||
del request
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=jsonable_encoder({"detail": clean_validation_errors(exc.errors())}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
|
||||
class TestTasksV2:
|
||||
"""Tests for the /v2/async-jobs-jobs endpoints."""
|
||||
@@ -174,3 +178,163 @@ class TestTasksV2:
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Forbidden"}
|
||||
|
||||
|
||||
def test_validation_preserves_ordinary_input(client):
|
||||
"""Validation errors keep input values that are not SecretStr."""
|
||||
canary = "private-meeting-content"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/summarize",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={"user_sub": "user", "content": {"unexpected": canary}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary in response.text
|
||||
assert response.json()["detail"] == [
|
||||
{
|
||||
"type": "string_type",
|
||||
"loc": ["body", "content"],
|
||||
"msg": "Input should be a valid string",
|
||||
"input": {"unexpected": canary},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_validation_preserves_context(client):
|
||||
"""Validator errors retain location, message and serializable context."""
|
||||
canary = "private-invalid-url-signature"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": f"invalid-url?signature={canary}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary not in response.text
|
||||
assert response.json()["detail"] == [
|
||||
{
|
||||
"type": "url_parsing",
|
||||
"loc": ["body", "cloud_storage_url"],
|
||||
"msg": "Input should be a valid URL, relative URL without a base",
|
||||
"input": "[redacted]",
|
||||
"ctx": {"error": "relative URL without a base"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@patch(
|
||||
"summary.api.route.tasks_v2.process_audio_transcribe_v2_task.apply_async",
|
||||
return_value=MagicMock(id="transcribe-job"),
|
||||
)
|
||||
def test_signed_urls_are_unwrapped_for_task_submission(mock_apply_async, client):
|
||||
"""API input secrets reach Celery intact, including nested metadata URLs."""
|
||||
signed_url = "https://storage.example.com/file?signature=deterministic-signature"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": signed_url,
|
||||
"metadata": {
|
||||
"cloud_storage_url": signed_url,
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"ended_at": "2026-01-01T11:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = mock_apply_async.call_args.kwargs["args"][0]
|
||||
assert payload["cloud_storage_url"] == signed_url
|
||||
assert payload["metadata"]["cloud_storage_url"] == signed_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
("transcribe", "transcript", "transcription_data_url"),
|
||||
("summarize", "summary", "summary_data_url"),
|
||||
],
|
||||
)
|
||||
@patch("summary.api.route.tasks_v2.celery")
|
||||
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||
def test_success_status_returns_usable_signed_url(
|
||||
mock_async_result, mock_celery, client, case
|
||||
):
|
||||
"""Success responses explicitly reveal result URLs for authorized clients."""
|
||||
job_type, payload_type, url_field = case
|
||||
signed_url = "https://storage.example.com/result?signature=deterministic-signature"
|
||||
mock_celery.backend.client.exists.return_value = True
|
||||
mock_async_result.return_value = MagicMock(
|
||||
status="SUCCESS",
|
||||
args=[{"tenant_id": "test-tenant"}],
|
||||
result={"job_id": "job", url_field: signed_url},
|
||||
)
|
||||
response = client.get(
|
||||
f"/api/v2/async-jobs/{job_type}/job",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"job_id": "job",
|
||||
"type": payload_type,
|
||||
"status": "success",
|
||||
url_field: signed_url,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
("transcribe", "transcription_data_url"),
|
||||
("summarize", "summary_data_url"),
|
||||
],
|
||||
)
|
||||
@patch("summary.api.route.tasks_v2.celery")
|
||||
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||
def test_invalid_success_result_hides_revealed_url(
|
||||
mock_async_result, mock_celery, client, case
|
||||
):
|
||||
"""Missing result fields cannot retain a raw signed URL in error details."""
|
||||
job_type, url_field = case
|
||||
canary = "private-result-signature"
|
||||
mock_celery.backend.client.exists.return_value = True
|
||||
mock_async_result.return_value = MagicMock(
|
||||
status="SUCCESS",
|
||||
args=[{"tenant_id": "test-tenant"}],
|
||||
result={url_field: f"https://storage.example.com/result?signature={canary}"},
|
||||
)
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
client.get(
|
||||
f"/api/v2/async-jobs/{job_type}/job",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
assert exc.value.errors()[0]["type"] == "missing"
|
||||
assert exc.value.errors()[0]["loc"] == ("job_id",)
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
assert canary not in str(exc.value)
|
||||
assert canary not in exc.value.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nested_error", [False, True])
|
||||
def test_missing_field_errors_preserve_raw_json_strings(client, nested_error):
|
||||
"""Raw JSON input is retained when it has not been converted to SecretStr."""
|
||||
canary = "private-validation-signature"
|
||||
signed_url = f"https://storage.example.com/recording?signature={canary}"
|
||||
payload = {"language": "en", "cloud_storage_url": signed_url}
|
||||
if nested_error:
|
||||
payload["user_sub"] = "user"
|
||||
payload["metadata"] = {"cloud_storage_url": signed_url}
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary in response.text
|
||||
assert all(error["type"] == "missing" for error in response.json()["detail"])
|
||||
expected_input = payload["metadata"] if nested_error else payload
|
||||
assert all(error["input"] == expected_input for error in response.json()["detail"])
|
||||
|
||||
@@ -22,6 +22,12 @@ def get_settings_override():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_override():
|
||||
"""Provide isolated settings containing the test tenant."""
|
||||
return get_settings_override()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
"""Provide a FastAPI TestClient for tests."""
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Secret URLs stay masked until explicitly serialized for transport."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from kombu.serialization import dumps, loads
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from summary.core import celery_worker
|
||||
from summary.core.models import (
|
||||
PushToDocsTranscriptConfig,
|
||||
RecordingMetadata,
|
||||
TranscribeTaskApiRequest,
|
||||
TranscribeTaskJob,
|
||||
)
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookSuccessPayload,
|
||||
TranscribeWebhookSuccessPayload,
|
||||
WhisperXResponse,
|
||||
)
|
||||
|
||||
CANARY = "private-signed-url-canary"
|
||||
SIGNED_URL = f"https://storage.example.com/recording.mp4?signature={CANARY}"
|
||||
METADATA = {
|
||||
"cloud_storage_url": SIGNED_URL,
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"ended_at": "2026-01-01T11:00:00Z",
|
||||
}
|
||||
DOCS_CONFIG = {
|
||||
"user_email": "user@example.com",
|
||||
"title": "Meeting",
|
||||
"download_link": SIGNED_URL,
|
||||
"form_link": SIGNED_URL,
|
||||
}
|
||||
MODEL_CASES = [
|
||||
(RecordingMetadata, METADATA, "cloud_storage_url"),
|
||||
(
|
||||
TranscribeTaskApiRequest,
|
||||
{"user_sub": "user", "language": "en", "cloud_storage_url": SIGNED_URL},
|
||||
"cloud_storage_url",
|
||||
),
|
||||
(PushToDocsTranscriptConfig, DOCS_CONFIG, "download_link"),
|
||||
(PushToDocsTranscriptConfig, DOCS_CONFIG, "form_link"),
|
||||
(
|
||||
TranscribeWebhookSuccessPayload,
|
||||
{"job_id": "job", "transcription_data_url": SIGNED_URL},
|
||||
"transcription_data_url",
|
||||
),
|
||||
(
|
||||
SummarizeWebhookSuccessPayload,
|
||||
{"job_id": "job", "summary_data_url": SIGNED_URL},
|
||||
"summary_data_url",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES)
|
||||
def test_signed_urls_are_masked_by_default(model, data, field):
|
||||
"""Reprs, ordinary dumps and JSON output mask all signed URL fields."""
|
||||
value = model.model_validate(data)
|
||||
secret = getattr(value, field)
|
||||
assert isinstance(secret, SecretStr)
|
||||
assert secret.get_secret_value() == SIGNED_URL
|
||||
assert CANARY not in repr(value)
|
||||
assert CANARY not in str(value.model_dump())
|
||||
assert CANARY not in value.model_dump_json()
|
||||
assert dump_for_transport(value)[field] == SIGNED_URL
|
||||
assert getattr(model.model_validate(value.model_dump()), field) == secret
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES)
|
||||
def test_missing_fields_do_not_echo_signed_urls(model, data, field):
|
||||
"""Root validation errors omit raw input even before secrets are wrapped."""
|
||||
required_field = (
|
||||
"job_id"
|
||||
if "job_id" in data
|
||||
else next(
|
||||
key for key in data if key not in {field, "download_link", "form_link"}
|
||||
)
|
||||
)
|
||||
invalid_data = {key: value for key, value in data.items() if key != required_field}
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate(invalid_data)
|
||||
assert CANARY not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES[:4])
|
||||
def test_invalid_signed_urls_have_sanitized_validation_messages(model, data, field):
|
||||
"""URL validation still rejects invalid URLs without quoting their value."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate({**data, field: f"not-a-url?signature={CANARY}"})
|
||||
assert CANARY not in str(exc.value)
|
||||
assert CANARY not in str(
|
||||
exc.value.errors(include_input=False, include_context=False)
|
||||
)
|
||||
assert exc.value.errors()[0]["type"] == "url_parsing"
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
|
||||
|
||||
def test_nested_task_urls_survive_broker_serialization():
|
||||
"""Nested secrets round-trip through Celery's JSON serializer as real URLs."""
|
||||
payload = TranscribeTaskJob(
|
||||
user_sub="user",
|
||||
language="en",
|
||||
cloud_storage_url=SIGNED_URL,
|
||||
tenant_id="tenant",
|
||||
received_at="2026-01-01T10:00:00Z",
|
||||
metadata=METADATA,
|
||||
push_to_docs_config=DOCS_CONFIG,
|
||||
)
|
||||
assert CANARY not in repr(payload)
|
||||
assert CANARY not in payload.model_dump_json()
|
||||
content_type, encoding, body = dumps(dump_for_transport(payload), serializer="json")
|
||||
restored = TranscribeTaskJob.model_validate(loads(body, content_type, encoding))
|
||||
assert restored.cloud_storage_url.get_secret_value() == SIGNED_URL
|
||||
assert restored.metadata.cloud_storage_url.get_secret_value() == SIGNED_URL
|
||||
assert restored.push_to_docs_config.download_link.get_secret_value() == SIGNED_URL
|
||||
assert restored.push_to_docs_config.form_link.get_secret_value() == SIGNED_URL
|
||||
|
||||
|
||||
def test_transcription_worker_uses_real_urls(monkeypatch):
|
||||
"""The worker unwraps input URLs and publishes usable signed result URLs."""
|
||||
transcription = WhisperXResponse(segments=())
|
||||
transcribe = Mock(return_value=transcription)
|
||||
monkeypatch.setattr(celery_worker, "transcribe_audio", transcribe)
|
||||
monkeypatch.setattr(celery_worker, "_should_push_to_docs", Mock(return_value=True))
|
||||
monkeypatch.setattr(
|
||||
celery_worker, "_should_auto_create_summary", Mock(return_value=False)
|
||||
)
|
||||
formatter = Mock(return_value="Formatted transcript")
|
||||
monkeypatch.setattr(celery_worker, "format_transcript", formatter)
|
||||
monkeypatch.setattr(celery_worker, "create_document_in_lasuite_docs", Mock())
|
||||
storage = Mock()
|
||||
storage.get_transcript_signed_url.return_value = SIGNED_URL
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
monkeypatch.setattr(celery_worker, "metadata_manager", Mock())
|
||||
publish = Mock()
|
||||
monkeypatch.setattr(celery_worker.call_webhook_v2_task, "apply_async", publish)
|
||||
task = celery_worker.process_audio_transcribe_v2_task
|
||||
monkeypatch.setattr(task.request, "id", "job")
|
||||
|
||||
result = task.run(
|
||||
{
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": SIGNED_URL,
|
||||
"tenant_id": "tenant",
|
||||
"received_at": "2026-01-01T10:00:00Z",
|
||||
"push_to_docs_config": DOCS_CONFIG,
|
||||
}
|
||||
)
|
||||
assert transcribe.call_args.kwargs["cloud_storage_url"] == SIGNED_URL
|
||||
assert formatter.call_args.args[3:] == (SIGNED_URL, SIGNED_URL)
|
||||
assert result["transcription_data_url"] == SIGNED_URL
|
||||
assert publish.call_args.kwargs["args"][0] == result
|
||||
|
||||
|
||||
def test_recording_metadata_url_is_unwrapped(monkeypatch):
|
||||
"""Speaker assignment fetches metadata using the real URL."""
|
||||
storage = Mock()
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
resolver = Mock()
|
||||
resolver.return_value.apply_to.return_value = {"segments": []}
|
||||
monkeypatch.setattr(celery_worker, "resolve_speaker_identities", resolver)
|
||||
celery_worker.resolve_speaker_identities_and_apply_to(
|
||||
transcription=WhisperXResponse(segments=()),
|
||||
recording_metadata=RecordingMetadata.model_validate(METADATA),
|
||||
task_id="job",
|
||||
)
|
||||
storage.read_cloud_storage_json.assert_called_once_with(SIGNED_URL)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Regression tests for observability privacy boundaries."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from summary.core import analytics, celery_worker, webhook_service
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.privacy import redact_url
|
||||
from summary.core.sentry import before_breadcrumb, before_send
|
||||
from summary.core.shared_models import SummarizeWebhookSuccessPayload
|
||||
|
||||
CANARY = "sensitive-canary"
|
||||
URL = f"https://user:password@example.com/file?signature={CANARY}#fragment"
|
||||
|
||||
|
||||
def test_url_redaction():
|
||||
"""Credentials and signed query strings are removed."""
|
||||
assert redact_url(URL) == "https://example.com/file?[redacted]"
|
||||
|
||||
|
||||
def test_sentry_scrubs_events_and_breadcrumbs():
|
||||
"""Untrusted exception messages and breadcrumb data never leave in events."""
|
||||
event = {
|
||||
"request": {"url": URL, "data": CANARY, "query_string": CANARY},
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"type": "ValueError",
|
||||
"value": CANARY,
|
||||
"stacktrace": {"frames": [{"vars": {"job_id": "job-1"}}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
"extra": {"url": URL},
|
||||
}
|
||||
result = before_send(event, {})
|
||||
assert CANARY not in str(result)
|
||||
assert result["exception"]["values"][0]["type"] == "ValueError"
|
||||
assert result["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"] == {
|
||||
"job_id": "job-1"
|
||||
}
|
||||
assert before_breadcrumb({"message": CANARY, "data": {"url": URL}}, {}) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["http", "HTTP", "hTtP", "https", "HTTPS", "hTtPs"])
|
||||
def test_sentry_url_scrubbing_is_case_insensitive(scheme):
|
||||
"""Scheme casing cannot bypass redaction of URLs nested in event strings."""
|
||||
event = {
|
||||
"extra": {
|
||||
"nested": [
|
||||
{
|
||||
"detail": (
|
||||
f"prefix <{scheme}://user:password@example.com/Path"
|
||||
f"?signature={CANARY}#fragment> suffix"
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
result = before_send(event, {})
|
||||
assert result["extra"]["nested"][0]["detail"] == (
|
||||
f"prefix <{scheme.lower()}://example.com/Path?[redacted]> suffix"
|
||||
)
|
||||
assert CANARY not in str(result)
|
||||
|
||||
|
||||
def test_llm_exception_does_not_include_upstream_message(caplog):
|
||||
"""LLM failures retain their category without logging upstream text."""
|
||||
observability = Mock(is_enabled=False)
|
||||
observability.get_openai_client.return_value.chat.completions.create.side_effect = (
|
||||
ValueError(CANARY)
|
||||
)
|
||||
with caplog.at_level(logging.DEBUG), pytest.raises(LLMException) as exc:
|
||||
LLMService(observability).call("system", CANARY, "test")
|
||||
assert CANARY not in str(exc.value)
|
||||
assert CANARY not in caplog.text
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_level", [logging.DEBUG, logging.INFO])
|
||||
def test_webhook_content_is_only_logged_at_debug(
|
||||
monkeypatch, caplog, settings_override, log_level
|
||||
):
|
||||
"""DEBUG retains response content; INFO and above only contain metadata."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"id": "document-123", "content": CANARY}
|
||||
monkeypatch.setattr(webhook_service.requests, "post", Mock(return_value=response))
|
||||
payload = SummarizeWebhookSuccessPayload(job_id="job", summary_data_url=URL)
|
||||
with caplog.at_level(log_level):
|
||||
webhook_service.call_webhook_v2(tenant_id="test-tenant", payload=payload)
|
||||
assert (
|
||||
webhook_service.requests.post.call_args.kwargs["json"]["summary_data_url"]
|
||||
== URL
|
||||
)
|
||||
assert all(
|
||||
CANARY not in record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno >= logging.INFO
|
||||
)
|
||||
if log_level == logging.DEBUG:
|
||||
assert any(
|
||||
record.levelno == logging.DEBUG and CANARY in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
else:
|
||||
assert CANARY not in caplog.text
|
||||
assert "Document document-123 submitted (HTTP 200)" in caplog.text
|
||||
|
||||
|
||||
def test_webhook_failure_keeps_retry_category(monkeypatch, settings_override):
|
||||
"""Webhook exceptions remain retryable without upstream text."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
monkeypatch.setattr(
|
||||
webhook_service.requests,
|
||||
"post",
|
||||
Mock(side_effect=requests.RequestException(URL)),
|
||||
)
|
||||
payload = SummarizeWebhookSuccessPayload(job_id="job", summary_data_url=URL)
|
||||
with pytest.raises(requests.RequestException) as exc:
|
||||
webhook_service.call_webhook_v2(tenant_id="test-tenant", payload=payload)
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
def test_analytics_failure_does_not_chain_payload(monkeypatch):
|
||||
"""Analytics errors do not expose arbitrary client exception text."""
|
||||
client = analytics.Analytics()
|
||||
client._client = Mock()
|
||||
client._client.capture.side_effect = ValueError(CANARY)
|
||||
with pytest.raises(analytics.AnalyticsException) as exc:
|
||||
client.capture("event", "user")
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_level", [logging.DEBUG, logging.INFO, logging.ERROR])
|
||||
def test_celery_log_filter_respects_log_level(log_level):
|
||||
"""Celery trace values remain available in DEBUG only."""
|
||||
record = logging.LogRecord(
|
||||
"celery.app.trace", log_level, "", 0, "%s", (CANARY,), None
|
||||
)
|
||||
record.data = {"name": "task", "id": "job", "return_value": URL}
|
||||
assert celery_worker.CeleryLogPrivacyFilter().filter(record)
|
||||
if log_level == logging.DEBUG:
|
||||
assert CANARY in record.getMessage()
|
||||
assert record.data["return_value"] == URL
|
||||
else:
|
||||
assert CANARY not in record.getMessage()
|
||||
assert record.data == {}
|
||||
|
||||
|
||||
def test_task_arguments_have_private_display_representations(monkeypatch):
|
||||
"""The actual task arguments are delivered but their event repr is hidden."""
|
||||
publish = Mock()
|
||||
monkeypatch.setattr(celery_worker.Task, "apply_async", publish)
|
||||
task = celery_worker.PrivateTask()
|
||||
task.apply_async(args=[CANARY], kwargs={"url": URL})
|
||||
assert publish.call_args.kwargs["args"] == [CANARY]
|
||||
assert publish.call_args.kwargs["argsrepr"] == "[Filtered]"
|
||||
assert publish.call_args.kwargs["kwargsrepr"] == "[Filtered]"
|
||||
|
||||
|
||||
def test_analytics_source_url_removes_signature():
|
||||
"""Task analytics keeps the source path without credentials or signature."""
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
from summary.core.models import TranscribeTaskJob # noqa: PLC0415
|
||||
|
||||
manager = analytics.MetadataManager()
|
||||
manager._is_disabled = False
|
||||
manager.has_task_id = Mock(return_value=False)
|
||||
manager._save_metadata = Mock()
|
||||
payload = TranscribeTaskJob(
|
||||
user_sub="user",
|
||||
tenant_id="tenant",
|
||||
cloud_storage_url=URL,
|
||||
language="en",
|
||||
received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
manager.create("job", payload)
|
||||
metadata = manager._save_metadata.call_args.args[1]
|
||||
assert metadata["source_url"] == "https://example.com/file?[redacted]"
|
||||
assert CANARY not in str(metadata)
|
||||
|
||||
|
||||
def test_invalid_llm_json_is_sanitized():
|
||||
"""Invalid structured LLM output retains a retryable LLM category."""
|
||||
with pytest.raises(LLMException) as exc:
|
||||
celery_worker.parse_llm_json(CANARY)
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
(None, "[redacted]"),
|
||||
("https://[invalid?secret=value", "[redacted]"),
|
||||
("https://example.com/path#fragment", "https://example.com/path"),
|
||||
("https://example.com/path?", "https://example.com/path?[redacted]"),
|
||||
],
|
||||
)
|
||||
def test_redact_url_edge_cases(url, expected):
|
||||
"""Malformed and non-string URLs do not expose credentials or query data."""
|
||||
assert redact_url(url) == expected
|
||||
@@ -1,27 +1,32 @@
|
||||
"""Tests for the Sentry configuration.
|
||||
|
||||
Each test raises an error from code handling meeting content and inspects the
|
||||
event Sentry would send: the content must be redacted, while harmless local
|
||||
variables are kept for debugging.
|
||||
event Sentry would send: sensitive content is redacted and useful locals remain.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
import requests
|
||||
import sentry_sdk
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.stub import Stubber
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
from sentry_sdk.transport import Transport
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core import file_service, webhook_service
|
||||
from summary.core import sentry as sentry_module
|
||||
from summary.core.file_service import FileService
|
||||
from summary.core.file_service import FileService, FileServiceException
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
from summary.core.shared_models import SummarizeWebhookSuccessPayload, WhisperXResponse
|
||||
|
||||
CANARY = "CANARY-MEETING-CONTENT"
|
||||
|
||||
@@ -51,8 +56,14 @@ def sentry_events() -> Iterator[SentryEvents]:
|
||||
transport=transport,
|
||||
send_default_pii=False,
|
||||
include_local_variables=True,
|
||||
before_send=sentry_module.before_send,
|
||||
before_breadcrumb=sentry_module.before_breadcrumb,
|
||||
event_scrubber=sentry_module.build_event_scrubber(),
|
||||
default_integrations=False,
|
||||
integrations=[
|
||||
LoggingIntegration(level=logging.DEBUG, event_level=None),
|
||||
StdlibIntegration(),
|
||||
],
|
||||
)
|
||||
|
||||
def flush() -> list[str]:
|
||||
@@ -63,6 +74,125 @@ def sentry_events() -> Iterator[SentryEvents]:
|
||||
sentry_sdk.init() # Disable Sentry for the following tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_recording_url() -> Iterator[str]:
|
||||
"""Serve a deterministic 404 through the real instrumented HTTP client."""
|
||||
|
||||
class MissingRecordingHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), MissingRecordingHandler) as server:
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield (
|
||||
f"http://127.0.0.1:{server.server_port}/recordings/recording-123.mp4"
|
||||
f"?X-Amz-Credential={CANARY}-credential"
|
||||
f"&X-Amz-Signature={CANARY}-signature"
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_sentry_download_404_excludes_signed_url(
|
||||
sentry_events: SentryEvents, missing_recording_url: str, caplog
|
||||
) -> None:
|
||||
"""A failed download excludes signed URLs from logs, locals and breadcrumbs."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
try:
|
||||
FileService()._download_from_cloud_storage_url(missing_recording_url)
|
||||
except FileServiceException as exc:
|
||||
assert exc.__cause__ is None
|
||||
assert exc.__suppress_context__
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("the download should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
assert CANARY not in caplog.text
|
||||
assert "/recordings/recording-123.mp4?[redacted]" in caplog.text
|
||||
parsed = json.loads(event)
|
||||
assert [item["type"] for item in parsed["exception"]["values"]] == [
|
||||
"FileServiceException"
|
||||
]
|
||||
download_vars = next(
|
||||
frame_vars for frame_vars in _local_vars(event) if "extension" in frame_vars
|
||||
)
|
||||
assert download_vars["extension"] == "'.mp4'"
|
||||
assert download_vars["cloud_storage_url"] == "[Filtered]"
|
||||
breadcrumbs = parsed["breadcrumbs"]["values"]
|
||||
assert any(crumb.get("type") == "http" for crumb in breadcrumbs)
|
||||
assert any(
|
||||
crumb.get("category") == "summary.core.file_service" for crumb in breadcrumbs
|
||||
)
|
||||
assert all("message" not in crumb and "data" not in crumb for crumb in breadcrumbs)
|
||||
|
||||
|
||||
def test_sentry_chained_http_error_excludes_signed_url(
|
||||
sentry_events: SentryEvents, missing_recording_url: str
|
||||
) -> None:
|
||||
"""Sentry also filters signed URLs in explicitly chained HTTP errors."""
|
||||
try:
|
||||
try:
|
||||
response = requests.get(missing_recording_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise FileServiceException("Download failed") from exc
|
||||
except FileServiceException:
|
||||
sentry_sdk.capture_exception()
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
exceptions = json.loads(event)["exception"]["values"]
|
||||
assert [item["type"] for item in exceptions] == [
|
||||
"HTTPError",
|
||||
"FileServiceException",
|
||||
]
|
||||
assert all(item["value"] == "[Filtered]" for item in exceptions)
|
||||
|
||||
|
||||
def test_sentry_excludes_webhook_debug_content(
|
||||
sentry_events: SentryEvents, monkeypatch, settings_override, caplog
|
||||
) -> None:
|
||||
"""Raw DEBUG responses stay in application logs and out of Sentry events."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
signed_url = f"https://storage.example.com/result?signature={CANARY}"
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {
|
||||
"id": "document-123",
|
||||
"content": CANARY,
|
||||
"url": signed_url,
|
||||
}
|
||||
monkeypatch.setattr(webhook_service.requests, "post", Mock(return_value=response))
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
webhook_service.call_webhook_v2(
|
||||
tenant_id="test-tenant",
|
||||
payload=SummarizeWebhookSuccessPayload(
|
||||
job_id="job", summary_data_url=signed_url
|
||||
),
|
||||
)
|
||||
try:
|
||||
raise RuntimeError("A later failure")
|
||||
except RuntimeError:
|
||||
sentry_sdk.capture_exception()
|
||||
|
||||
assert CANARY in caplog.text
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
assert any(
|
||||
crumb.get("category") == "summary.core.webhook_service"
|
||||
and crumb.get("level") == "debug"
|
||||
for crumb in json.loads(event)["breadcrumbs"]["values"]
|
||||
)
|
||||
|
||||
|
||||
def _transcript() -> WhisperXResponse:
|
||||
return WhisperXResponse.model_validate(
|
||||
{
|
||||
@@ -121,7 +251,7 @@ def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]:
|
||||
def test_sentry_store_transcript_failure_redacts_transcript(
|
||||
sentry_events: SentryEvents, s3_stubber: Stubber
|
||||
) -> None:
|
||||
"""A failed S3 upload does not send the transcript, but keeps the job id."""
|
||||
"""A failed S3 upload redacts the transcript while retaining useful locals."""
|
||||
s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest")
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Formatted validation errors hide input values, including after pickling."""
|
||||
|
||||
import pickle
|
||||
import traceback
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
CANARY = "private-validation-canary"
|
||||
|
||||
|
||||
def test_formatted_errors_hide_ordinary_input_after_pickle_roundtrip():
|
||||
"""Formatting hides raw input even when diagnostics intentionally retain it."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[{"type": "int_parsing", "loc": ("field",), "input": CANARY}],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
restored = pickle.loads(pickle.dumps(cleaned)) # noqa: S301 -- own test exception
|
||||
for error in (cleaned, restored):
|
||||
assert error.errors()[0]["input"] == CANARY
|
||||
assert CANARY not in str(error)
|
||||
assert CANARY not in repr(error)
|
||||
assert CANARY not in "".join(traceback.format_exception(error))
|
||||
assert error.errors()[0]["type"] == "int_parsing"
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Validation cleanup preserves diagnostics without retaining SecretStr values."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pickle
|
||||
import traceback
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import Retry
|
||||
from celery.utils.serialization import get_pickleable_exception
|
||||
from fastapi import Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
from summary.core import celery_worker
|
||||
from summary.core.exceptions import CleanedValidationError, clean_validation_errors
|
||||
from summary.main import validation_error_handler
|
||||
|
||||
CANARY = "private-validation-canary"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom", [False, True])
|
||||
def test_cleaned_validation_error_preserves_details_and_pickles(custom):
|
||||
"""Builtin and custom validation errors retain their type, location and message."""
|
||||
error_type = (
|
||||
PydanticCustomError(
|
||||
"custom_validation", "Invalid value: {reason}", {"reason": "test"}
|
||||
)
|
||||
if custom
|
||||
else "int_type"
|
||||
)
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[
|
||||
{"type": error_type, "loc": ("secret",), "input": SecretStr(CANARY)},
|
||||
{"type": "int_parsing", "loc": ("ordinary",), "input": "not-an-int"},
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
original_details = original.errors()
|
||||
details = cleaned.errors()
|
||||
assert isinstance(cleaned, ValidationError)
|
||||
assert cleaned.title == original.title
|
||||
assert cleaned.error_count() == original.error_count()
|
||||
assert details[0] == {**original_details[0], "input": "[redacted]"}
|
||||
assert details[1] == original_details[1]
|
||||
assert isinstance(original.errors()[0]["input"], SecretStr)
|
||||
assert CANARY not in str(cleaned)
|
||||
assert CANARY not in cleaned.json()
|
||||
serialized = pickle.dumps(cleaned)
|
||||
assert CANARY.encode() not in serialized
|
||||
restored = pickle.loads(serialized) # noqa: S301 -- own in-memory test exception
|
||||
assert isinstance(restored, CleanedValidationError)
|
||||
assert restored.errors() == details
|
||||
assert isinstance(get_pickleable_exception(cleaned), CleanedValidationError)
|
||||
|
||||
|
||||
def test_cleanup_masks_nested_secrets_and_preserves_plain_input():
|
||||
"""Missing-field inputs keep ordinary values while masking nested secrets."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"NestedModel",
|
||||
[
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ("required",),
|
||||
"input": {
|
||||
"ordinary": "keep me",
|
||||
"nested": [SecretStr(CANARY), {"url": SecretStr(CANARY)}],
|
||||
"tuple": (SecretStr(CANARY), 42),
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
assert cleaned.errors()[0]["input"] == {
|
||||
"ordinary": "keep me",
|
||||
"nested": ["[redacted]", {"url": "[redacted]"}],
|
||||
"tuple": ("[redacted]", 42),
|
||||
}
|
||||
assert CANARY not in cleaned.json()
|
||||
assert CANARY.encode() not in pickle.dumps(cleaned)
|
||||
|
||||
|
||||
def test_cleanup_does_not_retain_validator_exception_objects():
|
||||
"""Validator context keeps its readable message without secret-bearing args."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[
|
||||
{
|
||||
"type": "value_error",
|
||||
"loc": (),
|
||||
"input": SecretStr(CANARY),
|
||||
"ctx": {"error": ValueError(SecretStr(CANARY))},
|
||||
}
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
assert cleaned.errors()[0]["ctx"] == {"error": "**********"}
|
||||
assert CANARY.encode() not in pickle.dumps(cleaned)
|
||||
|
||||
|
||||
def test_clean_validation_errors_keeps_all_other_fields():
|
||||
"""The API cleanup preserves diagnostics and nonsecret input."""
|
||||
details = [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ("body", "field"),
|
||||
"msg": "Invalid value",
|
||||
"url": "https://example.com/errors/test",
|
||||
"ctx": {"limit": 3},
|
||||
"input": "ordinary input",
|
||||
}
|
||||
]
|
||||
assert clean_validation_errors(details) == details
|
||||
assert (
|
||||
json.loads(json.dumps(clean_validation_errors(details)))[0]["input"]
|
||||
== "ordinary input"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
[
|
||||
"call_webhook_v2_task",
|
||||
"process_audio_transcribe_v2_task",
|
||||
"summarize_v2_task",
|
||||
"task_started_transcript",
|
||||
"task_started_summary",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("secret_wrapped", [False, True])
|
||||
def test_task_validation_uses_cleaned_errors(handler_name, secret_wrapped):
|
||||
"""Task entry points and prerun signals consistently suppress the original error."""
|
||||
handler = getattr(celery_worker, handler_name)
|
||||
signed_url = f"https://storage.example.com/file?signature={CANARY}"
|
||||
value = SecretStr(signed_url) if secret_wrapped else signed_url
|
||||
payload = {"cloud_storage_url": value, "content": value}
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
if handler_name.startswith("task_started"):
|
||||
handler(task_id="job", args=[payload])
|
||||
elif handler_name == "call_webhook_v2_task":
|
||||
handler.run(
|
||||
payload={
|
||||
"type": "summary",
|
||||
"status": "success",
|
||||
"summary_data_url": value,
|
||||
},
|
||||
tenant_id="tenant",
|
||||
)
|
||||
elif handler_name == "summarize_v2_task":
|
||||
# Exercise validation without triggering the summary task's retries.
|
||||
handler._orig_run(payload=payload)
|
||||
else:
|
||||
handler.run(payload=payload)
|
||||
assert exc.value.__suppress_context__
|
||||
assert CANARY not in "".join(traceback.format_exception(exc.value))
|
||||
assert CANARY not in exc.value.json()
|
||||
assert CANARY.encode() not in pickle.dumps(exc.value)
|
||||
|
||||
|
||||
def test_upstream_transcription_validation_hides_raw_input(monkeypatch):
|
||||
"""Invalid ASR responses do not remain in structured validation errors."""
|
||||
storage = MagicMock()
|
||||
storage.prepare_audio_file.return_value.__enter__.return_value = (
|
||||
Mock(),
|
||||
{"duration": 1},
|
||||
)
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
monkeypatch.setattr(celery_worker, "metadata_manager", Mock())
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"text": {"content": CANARY}}
|
||||
monkeypatch.setattr(celery_worker.requests, "post", Mock(return_value=response))
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
celery_worker.transcribe_audio(
|
||||
task_id="job", language="en", cloud_storage_url="https://example.com/audio"
|
||||
)
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
assert CANARY not in exc.value.json()
|
||||
assert CANARY.encode() not in pickle.dumps(exc.value)
|
||||
|
||||
|
||||
def test_api_error_handler_uses_secret_aware_cleanup():
|
||||
"""API errors mask typed secrets while retaining other values and context."""
|
||||
errors = [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ("body", "field"),
|
||||
"msg": "Invalid value",
|
||||
"input": {"url": SecretStr(CANARY), "ordinary": "keep me"},
|
||||
"ctx": {"limit": 3},
|
||||
"url": "https://example.com/errors/test",
|
||||
},
|
||||
]
|
||||
response = asyncio.run(
|
||||
validation_error_handler(
|
||||
Request({"type": "http"}), RequestValidationError(errors)
|
||||
)
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert CANARY.encode() not in response.body
|
||||
assert json.loads(response.body)["detail"] == [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ["body", "field"],
|
||||
"msg": "Invalid value",
|
||||
"input": {"url": "[redacted]", "ordinary": "keep me"},
|
||||
"ctx": {"limit": 3},
|
||||
"url": "https://example.com/errors/test",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_summary_task_retries_with_the_cleaned_exception(monkeypatch):
|
||||
"""The summary task still retries validation failures using the safe exception."""
|
||||
retry = Mock(side_effect=Retry())
|
||||
monkeypatch.setattr(celery_worker.summarize_v2_task, "retry", retry)
|
||||
with pytest.raises(Retry):
|
||||
celery_worker.summarize_v2_task.run(payload={"received_at": SecretStr(CANARY)})
|
||||
error = retry.call_args.kwargs["exc"]
|
||||
assert isinstance(error, CleanedValidationError)
|
||||
assert CANARY.encode() not in pickle.dumps(error)
|
||||
Reference in New Issue
Block a user