(backend) introduce a creation callback endpoint

Necessary in cross browser context situation, where we need to
pass data of a room newly created between two different windows.

This happens in Calendar integration.
This commit is contained in:
lebaudantoine
2025-03-28 19:59:39 +01:00
committed by aleb_the_flash
parent 1f603ce17b
commit 4e1a4be650
6 changed files with 236 additions and 1 deletions

View File

@@ -209,3 +209,17 @@ class ParticipantEntrySerializer(serializers.Serializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer.""" """Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only") raise NotImplementedError("StartRecordingSerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")

View File

@@ -47,6 +47,7 @@ from core.services.lobby import (
LobbyParticipantNotFound, LobbyParticipantNotFound,
LobbyService, LobbyService,
) )
from core.services.room_creation import RoomCreation
from . import permissions, serializers from . import permissions, serializers
@@ -186,6 +187,12 @@ class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
scope = "request_entry" scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet( class RoomViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
@@ -268,6 +275,9 @@ class RoomViewSet(
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
) )
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
@decorators.action( @decorators.action(
detail=True, detail=True,
methods=["post"], methods=["post"],
@@ -460,6 +470,31 @@ class RoomViewSet(
{"status": "error", "message": str(e)}, status=status_code {"status": "error", "message": str(e)}, status=status_code
) )
@decorators.action(
detail=False,
methods=["POST"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
Designed for interoperability across iframes, popups, and other contexts,
even on the same domain, bypassing browser security restrictions on direct communication.
"""
serializer = serializers.CreationCallbackSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
room = RoomCreation().get_callback_state(
callback_id=serializer.validated_data.get("callback_id")
)
return drf_response.Response(
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
)
class ResourceAccessListModelMixin: class ResourceAccessListModelMixin:
"""List mixin for resource access API.""" """List mixin for resource access API."""

View File

@@ -0,0 +1,37 @@
"""Room creation service."""
from django.conf import settings
from django.core.cache import cache
class RoomCreation:
"""Room creation related methods"""
@staticmethod
def _get_cache_key(callback_id):
"""Generate a standardized cache key for room creation callbacks."""
return f"room-creation-callback_{callback_id}"
def persist_callback_state(self, callback_id: str, room) -> None:
"""Store room data in cache using the callback ID as an identifier."""
data = {
"slug": room.slug,
}
cache.set(
self._get_cache_key(callback_id),
data,
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
)
def get_callback_state(self, callback_id: str) -> dict:
"""Retrieve and clear cached room data for the given callback ID."""
cache_key = self._get_cache_key(callback_id)
data = cache.get(cache_key)
if not data:
return {}
cache.delete(cache_key)
return data

View File

@@ -2,6 +2,9 @@
Test rooms API endpoints in the Meet core app: create. Test rooms API endpoints in the Meet core app: create.
""" """
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -11,6 +14,15 @@ from ...models import Room
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous(): def test_api_rooms_create_anonymous():
"""Anonymous users should not be allowed to create rooms.""" """Anonymous users should not be allowed to create rooms."""
client = APIClient() client = APIClient()
@@ -26,7 +38,7 @@ def test_api_rooms_create_anonymous():
assert Room.objects.exists() is False assert Room.objects.exists() is False
def test_api_rooms_create_authenticated(): def test_api_rooms_create_authenticated(reset_cache):
""" """
Authenticated users should be able to create rooms and should automatically be declared Authenticated users should be able to create rooms and should automatically be declared
as owner of the newly created room. as owner of the newly created room.
@@ -49,6 +61,33 @@ def test_api_rooms_create_authenticated():
assert room.slug == "my-room" assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True assert room.accesses.filter(role="owner", user=user).exists() is True
rooms_data = cache.keys("room-creation-callback_*")
assert not rooms_data
def test_api_rooms_create_generation_cache(reset_cache):
"""
Authenticated users creating a room with a callback ID should have room data stored in cache.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{"name": "my room", "callback_id": "1234"},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.name == "my room"
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
room_data = cache.get("room-creation-callback_1234")
assert room_data.get("slug") == "my-room"
def test_api_rooms_create_authenticated_existing_slug(): def test_api_rooms_create_authenticated_existing_slug():
""" """

View File

@@ -0,0 +1,98 @@
"""
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...factories import UserFactory
pytestmark = pytest.mark.django_db
# Tests for creation_callback endpoint
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous(reset_cache):
"""Anonymous user can retrieve room data once using a valid callback ID."""
client = APIClient()
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
# Data should be cleared after retrieval
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_empty_cache():
"""Valid callback ID return empty room data when nothing is stored in the cache."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_missing_callback_id():
"""Requests without a callback ID properly fail with a 400 status code."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{},
)
assert response.status_code == 400
def test_api_rooms_create_authenticated(reset_cache):
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
user = UserFactory()
client = APIClient()
client.force_login(user)
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}

View File

@@ -272,6 +272,11 @@ class Base(Configuration):
environ_name="REQUEST_ENTRY_THROTTLE_RATES", environ_name="REQUEST_ENTRY_THROTTLE_RATES",
environ_prefix=None, environ_prefix=None,
), ),
"creation_callback": values.Value(
default="600/minute",
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
}, },
} }
@@ -528,6 +533,13 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes
environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT",
environ_prefix=None,
)
# pylint: disable=invalid-name # pylint: disable=invalid-name
@property @property
def ENVIRONMENT(self): def ENVIRONMENT(self):