mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 13:48:26 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2bd8e4b3 |
@@ -8,6 +8,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) update a room's access level and configuration from the external API
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||||
|
|||||||
+78
-3
@@ -16,11 +16,11 @@ info:
|
|||||||
* `rooms:list` – List rooms accessible to the delegated user.
|
* `rooms:list` – List rooms accessible to the delegated user.
|
||||||
* `rooms:retrieve` – Retrieve details of a specific room.
|
* `rooms:retrieve` – Retrieve details of a specific room.
|
||||||
* `rooms:create` – Create new rooms.
|
* `rooms:create` – Create new rooms.
|
||||||
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
* `rooms:update` – Update the access level and configuration of existing rooms.
|
||||||
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### Upcoming Features
|
||||||
|
|
||||||
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||||
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||||
|
|
||||||
@@ -310,6 +310,67 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/RoomNotFoundError'
|
$ref: '#/components/responses/RoomNotFoundError'
|
||||||
|
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- Rooms
|
||||||
|
summary: Update a room
|
||||||
|
description: |
|
||||||
|
Partially updates a room. Only the delegated user's rooms where they are
|
||||||
|
administrator or owner can be updated; any other role gets a `403`.
|
||||||
|
|
||||||
|
**Updatable fields:** `access_level` and `configuration`. Every other field
|
||||||
|
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
|
||||||
|
|
||||||
|
`configuration` is replaced as a whole, it is not merged with the stored one.
|
||||||
|
Send the complete object you want the room to end up with.
|
||||||
|
|
||||||
|
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
|
||||||
|
operationId: updateRoom
|
||||||
|
security:
|
||||||
|
- BearerAuth: [rooms:update]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Room UUID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoomUpdate'
|
||||||
|
examples:
|
||||||
|
accessLevelOnly:
|
||||||
|
summary: Change the access level
|
||||||
|
value:
|
||||||
|
access_level: "restricted"
|
||||||
|
configurationOnly:
|
||||||
|
summary: Replace the room configuration
|
||||||
|
value:
|
||||||
|
configuration:
|
||||||
|
everyone_can_mute: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Room updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Room'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequestError'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/UnauthorizedError'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/RoomNotFoundError'
|
||||||
|
'405':
|
||||||
|
description: |
|
||||||
|
Method not allowed, `PUT` is not supported on this endpoint.
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -386,6 +447,17 @@ components:
|
|||||||
configuration:
|
configuration:
|
||||||
$ref: '#/components/schemas/RoomConfiguration'
|
$ref: '#/components/schemas/RoomConfiguration'
|
||||||
|
|
||||||
|
RoomUpdate:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Fields that can be updated on an existing room. Both are optional, omitted
|
||||||
|
fields keep their current value.
|
||||||
|
properties:
|
||||||
|
access_level:
|
||||||
|
$ref: '#/components/schemas/RoomAccessLevel'
|
||||||
|
configuration:
|
||||||
|
$ref: '#/components/schemas/RoomConfiguration'
|
||||||
|
|
||||||
RoomConfiguration:
|
RoomConfiguration:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -427,6 +499,9 @@ components:
|
|||||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||||
|
|
||||||
|
`public` is rejected with a `400` unless the deployment explicitly enables it
|
||||||
|
for this API. This applies both when creating a room and when updating one.
|
||||||
example: "trusted"
|
example: "trusted"
|
||||||
|
|
||||||
Room:
|
Room:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ info:
|
|||||||
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||||
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||||
* `lasuite_visio:rooms:create` – Create new rooms.
|
* `lasuite_visio:rooms:create` – Create new rooms.
|
||||||
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
* `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms.
|
||||||
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### Upcoming Features
|
||||||
@@ -206,6 +206,67 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/RoomNotFoundError'
|
$ref: '#/components/responses/RoomNotFoundError'
|
||||||
|
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- Rooms
|
||||||
|
summary: Update a room
|
||||||
|
description: |
|
||||||
|
Partially updates a room. Only rooms where the user is administrator or
|
||||||
|
owner can be updated; any other role gets a `403`.
|
||||||
|
|
||||||
|
**Updatable fields:** `access_level` and `configuration`. Every other field
|
||||||
|
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
|
||||||
|
|
||||||
|
`configuration` is replaced as a whole, it is not merged with the stored one.
|
||||||
|
Send the complete object you want the room to end up with.
|
||||||
|
|
||||||
|
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
|
||||||
|
operationId: updateRoom
|
||||||
|
security:
|
||||||
|
- BearerAuth: [rooms:update]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Room UUID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoomUpdate'
|
||||||
|
examples:
|
||||||
|
accessLevelOnly:
|
||||||
|
summary: Change the access level
|
||||||
|
value:
|
||||||
|
access_level: "restricted"
|
||||||
|
configurationOnly:
|
||||||
|
summary: Replace the room configuration
|
||||||
|
value:
|
||||||
|
configuration:
|
||||||
|
everyone_can_mute: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Room updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Room'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequestError'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/UnauthorizedError'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/RoomNotFoundError'
|
||||||
|
'405':
|
||||||
|
description: |
|
||||||
|
Method not allowed, `PUT` is not supported on this endpoint.
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -227,6 +288,17 @@ components:
|
|||||||
configuration:
|
configuration:
|
||||||
$ref: '#/components/schemas/RoomConfiguration'
|
$ref: '#/components/schemas/RoomConfiguration'
|
||||||
|
|
||||||
|
RoomUpdate:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Fields that can be updated on an existing room. Both are optional, omitted
|
||||||
|
fields keep their current value.
|
||||||
|
properties:
|
||||||
|
access_level:
|
||||||
|
$ref: '#/components/schemas/RoomAccessLevel'
|
||||||
|
configuration:
|
||||||
|
$ref: '#/components/schemas/RoomConfiguration'
|
||||||
|
|
||||||
RoomConfiguration:
|
RoomConfiguration:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -268,6 +340,9 @@ components:
|
|||||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||||
|
|
||||||
|
`public` is rejected with a `400` unless the deployment explicitly enables it
|
||||||
|
for this API. This applies both when creating a room and when updating one.
|
||||||
example: "trusted"
|
example: "trusted"
|
||||||
|
|
||||||
Room:
|
Room:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
ROOM_UPDATED = "room_updated"
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
# Roomkit (meeting-room SIP devices)
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
ROOMKIT_JOINED = "roomkit_joined"
|
||||||
|
|||||||
@@ -89,11 +89,7 @@ from core.services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
from core.services.room_creation import RoomCreation
|
||||||
from core.services.room_management import (
|
from core.services.room_management import sync_room_metadata
|
||||||
RoomManagement,
|
|
||||||
RoomManagementException,
|
|
||||||
RoomNotFoundException,
|
|
||||||
)
|
|
||||||
from core.services.room_roles import (
|
from core.services.room_roles import (
|
||||||
RoomRoleError,
|
RoomRoleError,
|
||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
@@ -370,26 +366,7 @@ class RoomViewSet(
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
metadata = {
|
sync_room_metadata(room)
|
||||||
"configuration": room.configuration,
|
|
||||||
"access_level": room.access_level,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
RoomManagement().update_metadata(
|
|
||||||
room_name=str(room.id),
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
except RoomNotFoundException:
|
|
||||||
logger.info(
|
|
||||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
|
||||||
room.id,
|
|
||||||
)
|
|
||||||
except RoomManagementException:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to sync metadata to LiveKit for room %s",
|
|
||||||
room.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
from core.services.room_management import sync_room_metadata
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -142,6 +143,7 @@ class RoomViewSet(
|
|||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
):
|
):
|
||||||
"""Application-delegated API for room management.
|
"""Application-delegated API for room management.
|
||||||
@@ -154,8 +156,12 @@ class RoomViewSet(
|
|||||||
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
||||||
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
||||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||||
|
- partial_update: Update a room's access level and configuration, for
|
||||||
|
administrators and owners only (requires 'rooms:update' scope)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||||
|
|
||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
authentication.AddonsJWTAuthentication,
|
authentication.AddonsJWTAuthentication,
|
||||||
@@ -189,6 +195,38 @@ class RoomViewSet(
|
|||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
|
def _track_room_event(self, room, event, **extra_properties):
|
||||||
|
"""Log a room operation for auditing and forward it to analytics."""
|
||||||
|
|
||||||
|
auth_method = type(self.request.successful_authenticator).__name__
|
||||||
|
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
|
||||||
|
logger.info(
|
||||||
|
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
|
||||||
|
event.removeprefix("room_"),
|
||||||
|
room.id,
|
||||||
|
self.request.user.id,
|
||||||
|
client_id,
|
||||||
|
auth_method,
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
self.request.user,
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": room.access_level,
|
||||||
|
"client_id": client_id,
|
||||||
|
"external_api": True,
|
||||||
|
"auth_method": auth_method,
|
||||||
|
**extra_properties,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room."""
|
"""Set the current user as owner of the newly created room."""
|
||||||
room = serializer.save()
|
room = serializer.save()
|
||||||
@@ -198,27 +236,31 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_method = type(self.request.successful_authenticator).__name__
|
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
|
||||||
|
|
||||||
# Log for auditing
|
def perform_update(self, serializer):
|
||||||
logger.info(
|
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
||||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
|
||||||
room.id,
|
previous_values = {
|
||||||
self.request.user.id,
|
"access_level": serializer.instance.access_level,
|
||||||
client_id,
|
"configuration": serializer.instance.configuration,
|
||||||
auth_method,
|
}
|
||||||
|
|
||||||
|
room = serializer.save()
|
||||||
|
|
||||||
|
# Report the fields that actually changed, not the ones that were submitted.
|
||||||
|
updated_fields = sorted(
|
||||||
|
field
|
||||||
|
for field, previous_value in previous_values.items()
|
||||||
|
if getattr(room, field) != previous_value
|
||||||
)
|
)
|
||||||
|
|
||||||
analytics.capture(
|
if updated_fields:
|
||||||
self.request.user,
|
sync_room_metadata(room)
|
||||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
|
||||||
{
|
self._track_room_event(
|
||||||
"room_id": str(room.pk),
|
room,
|
||||||
"access_level": room.access_level,
|
analytics.AnalyticsEvent.ROOM_UPDATED,
|
||||||
"client_id": client_id,
|
updated_fields=updated_fields,
|
||||||
"external_api": True,
|
previous_access_level=previous_values["access_level"],
|
||||||
"auth_method": auth_method,
|
|
||||||
"$set": {"email": self.request.user.email},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
|||||||
else:
|
else:
|
||||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||||
|
|||||||
@@ -116,3 +116,32 @@ class RoomManagement:
|
|||||||
raise RoomManagementException("Could not delete room") from e
|
raise RoomManagementException("Could not delete room") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_room_metadata(room):
|
||||||
|
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||||
|
|
||||||
|
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
|
||||||
|
should never fail the request that triggered the update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"configuration": room.configuration,
|
||||||
|
"access_level": room.access_level,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
RoomManagement().update_metadata(
|
||||||
|
room_name=str(room.id),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||||
|
room.id,
|
||||||
|
)
|
||||||
|
except RoomManagementException:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to sync metadata to LiveKit for room %s",
|
||||||
|
room.id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -381,38 +381,11 @@ def test_api_rooms_update_administrators_of_another():
|
|||||||
assert other_room.slug == "old-name"
|
assert other_room.slug == "old-name"
|
||||||
|
|
||||||
|
|
||||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
|
||||||
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
@patch.object(RoomManagement, "update_metadata")
|
||||||
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||||
user = UserFactory()
|
|
||||||
room = RoomFactory(
|
|
||||||
users=[(user, random.choice(["administrator", "owner"]))],
|
|
||||||
configuration={},
|
|
||||||
)
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/rooms/{room.id!s}/",
|
|
||||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
room.refresh_from_db()
|
|
||||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
|
||||||
|
|
||||||
mock_update_metadata.assert_called_once_with(
|
|
||||||
room_name=str(room.id),
|
|
||||||
metadata={
|
|
||||||
"access_level": room.access_level,
|
|
||||||
"configuration": {"can_publish_sources": ["camera"]},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
|
|
||||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
|
||||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||||
|
mock_update_metadata.side_effect = exception
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
room = RoomFactory(
|
room = RoomFactory(
|
||||||
users=[(user, random.choice(["administrator", "owner"]))],
|
users=[(user, random.choice(["administrator", "owner"]))],
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
|
from core.factories import RoomFactory
|
||||||
|
from core.models import RoomAccessLevel
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
|
sync_room_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,3 +61,22 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
|||||||
RoomManagement().delete_room("room-abc")
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
mock_api.aclose.assert_awaited_once()
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
||||||
|
"""The room's configuration and access level are forwarded to LiveKit."""
|
||||||
|
room = RoomFactory.build(
|
||||||
|
access_level=RoomAccessLevel.RESTRICTED,
|
||||||
|
configuration={"everyone_can_mute": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_room_metadata(room)
|
||||||
|
|
||||||
|
mock_update_metadata.assert_called_once_with(
|
||||||
|
room_name=str(room.id),
|
||||||
|
metadata={
|
||||||
|
"configuration": {"everyone_can_mute": True},
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,8 +16,17 @@ import responses
|
|||||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.analytics import AnalyticsEvent
|
||||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
from core.models import (
|
||||||
|
Application,
|
||||||
|
ApplicationScope,
|
||||||
|
RoleChoices,
|
||||||
|
Room,
|
||||||
|
RoomAccessLevel,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from core.services.room_management import RoomManagement
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -880,6 +889,509 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
|
|||||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||||
|
def test_api_rooms_create_tracks_analytics(mock_capture):
|
||||||
|
"""Creating a room should emit a ROOM_CREATED analytics event."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||||
|
application = Application.objects.get()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/rooms/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
captured_user, event, properties = mock_capture.call_args[0]
|
||||||
|
|
||||||
|
assert captured_user == user
|
||||||
|
assert event == AnalyticsEvent.ROOM_CREATED
|
||||||
|
assert properties == {
|
||||||
|
"room_id": response.data["id"],
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
"client_id": str(application.client_id),
|
||||||
|
"external_api": True,
|
||||||
|
"auth_method": "ApplicationJWTAuthentication",
|
||||||
|
"$set": {"email": user.email},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_requires_authentication():
|
||||||
|
"""Updating a room without authentication should return 401."""
|
||||||
|
|
||||||
|
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_requires_scope():
|
||||||
|
"""Updating a room requires the ROOMS_UPDATE scope."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
# Token without ROOMS_UPDATE scope
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert (
|
||||||
|
"insufficient permissions. required scope: rooms:update"
|
||||||
|
in str(response.data).lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_no_scope():
|
||||||
|
"""Updating a room without any scope should return 403."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
token = generate_test_token(user, [])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "insufficient permissions." in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
|
||||||
|
"""An owner should be able to update the access level and the configuration."""
|
||||||
|
|
||||||
|
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
configuration={},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
"configuration": {"everyone_can_mute": True},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["id"] == str(room.id)
|
||||||
|
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
|
||||||
|
assert response.data["configuration"] == {"everyone_can_mute": True}
|
||||||
|
assert response.data["url"] == f"http://your-application.com/{room.slug}"
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
assert room.configuration == {"everyone_can_mute": True}
|
||||||
|
|
||||||
|
mock_update_metadata.assert_called_once_with(
|
||||||
|
room_name=str(room.id),
|
||||||
|
metadata={
|
||||||
|
"configuration": {"everyone_can_mute": True},
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
|
||||||
|
"""The configuration is replaced as a whole, it is not merged with the stored one."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"configuration": {"everyone_can_mute": False}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# The keys missing from the payload are dropped, not kept.
|
||||||
|
assert response.data["configuration"] == {"everyone_can_mute": False}
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == {"everyone_can_mute": False}
|
||||||
|
|
||||||
|
mock_update_metadata.assert_called_once_with(
|
||||||
|
room_name=str(room.id),
|
||||||
|
metadata={
|
||||||
|
"configuration": {"everyone_can_mute": False},
|
||||||
|
"access_level": room.access_level,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_administrator_success(mock_update_metadata):
|
||||||
|
"""An administrator should be able to update a room."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.ADMIN)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
|
||||||
|
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
configuration={},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.put(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
"configuration": {"everyone_can_mute": True},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||||
|
assert room.configuration == {}
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
|
||||||
|
"""Members and users without any role should not be able to update a room."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
users = [(user, role)] if role else []
|
||||||
|
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
|
||||||
|
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
expected_id, expected_name = str(room.id), room.name
|
||||||
|
expected_slug, expected_pin_code = room.slug, room.pin_code
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": "fake-name",
|
||||||
|
"slug": "fake-slug",
|
||||||
|
"pin_code": "000000",
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["id"] == expected_id
|
||||||
|
assert response.data["name"] == expected_name
|
||||||
|
assert response.data["slug"] == expected_slug
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert str(room.id) == expected_id
|
||||||
|
assert room.name == expected_name
|
||||||
|
assert room.slug == expected_slug
|
||||||
|
assert room.pin_code == expected_pin_code
|
||||||
|
|
||||||
|
# The one writable field in the payload was applied
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
|
||||||
|
"""Updating a room with unsupported configuration keys should fail."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"configuration": {"unsupported_flag": True}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "extra inputs are not permitted" in str(response.data).lower()
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == {}
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"invalid_configuration",
|
||||||
|
[
|
||||||
|
{"can_publish_sources": ["invalid-source"]},
|
||||||
|
{"everyone_can_mute": "invalid-value"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_rejects_invalid_configuration_values(
|
||||||
|
mock_update_metadata, invalid_configuration
|
||||||
|
):
|
||||||
|
"""Updating a room with invalid configuration values should fail."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"configuration": invalid_configuration},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == {}
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
|
||||||
|
"""Switching a room to public should be disabled for the external API by default."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.PUBLIC},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "public rooms are disabled" in str(response.data).lower()
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_public_access_enabled_with_settings(
|
||||||
|
mock_update_metadata, settings
|
||||||
|
):
|
||||||
|
"""Switching a room to public should be allowed when explicitly enabled."""
|
||||||
|
|
||||||
|
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.PUBLIC},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||||
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_unchanged_skips_livekit_sync(
|
||||||
|
mock_update_metadata, mock_capture
|
||||||
|
):
|
||||||
|
"""An update that changes nothing should not sync metadata nor report changes."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
configuration={"everyone_can_mute": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{
|
||||||
|
"access_level": RoomAccessLevel.TRUSTED,
|
||||||
|
"configuration": {"everyone_can_mute": True},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
# The event is still emitted for auditing, but reports an empty delta.
|
||||||
|
_, _, properties = mock_capture.call_args[0]
|
||||||
|
assert properties["updated_fields"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
||||||
|
"""Updating a room should emit a ROOM_UPDATED analytics event."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
configuration={},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
application = Application.objects.get()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
"configuration": {"everyone_can_mute": True},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
captured_user, event, properties = mock_capture.call_args[0]
|
||||||
|
|
||||||
|
assert captured_user == user
|
||||||
|
assert event == AnalyticsEvent.ROOM_UPDATED
|
||||||
|
assert properties == {
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
|
"updated_fields": ["access_level", "configuration"],
|
||||||
|
"previous_access_level": RoomAccessLevel.TRUSTED,
|
||||||
|
"client_id": str(application.client_id),
|
||||||
|
"external_api": True,
|
||||||
|
"auth_method": "ApplicationJWTAuthentication",
|
||||||
|
"$set": {"email": user.email},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_response_no_url(settings):
|
def test_api_rooms_response_no_url(settings):
|
||||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||||
settings.APPLICATION_BASE_URL = None
|
settings.APPLICATION_BASE_URL = None
|
||||||
@@ -1497,6 +2009,106 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_resource_server_updates_room_with_prefixed_scope(
|
||||||
|
mock_update_metadata, settings
|
||||||
|
):
|
||||||
|
"""A resource server token carrying the prefixed update scope should be accepted."""
|
||||||
|
|
||||||
|
user = UserFactory(sub="very-specific-sub")
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||||
|
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||||
|
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||||
|
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
settings.OIDC_VERIFY_SSL = False
|
||||||
|
settings.OIDC_TIMEOUT = 5
|
||||||
|
settings.OIDC_PROXY = None
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
|
"sub": "very-specific-sub",
|
||||||
|
"client_id": "some_service_provider",
|
||||||
|
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_resource_server_denies_room_update_without_update_scope(settings):
|
||||||
|
"""A resource server token without the update scope should be denied."""
|
||||||
|
|
||||||
|
user = UserFactory(sub="very-specific-sub")
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||||
|
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||||
|
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||||
|
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
settings.OIDC_VERIFY_SSL = False
|
||||||
|
settings.OIDC_TIMEOUT = 5
|
||||||
|
settings.OIDC_PROXY = None
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
|
"sub": "very-specific-sub",
|
||||||
|
"client_id": "some_service_provider",
|
||||||
|
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
# ==============================
|
||||||
# Addons
|
# Addons
|
||||||
# ==============================
|
# ==============================
|
||||||
@@ -1548,6 +2160,32 @@ def test_api_rooms_create_with_valid_addons_token():
|
|||||||
assert room.get_role(user) == RoleChoices.OWNER
|
assert room.get_role(user) == RoleChoices.OWNER
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(RoomManagement, "update_metadata")
|
||||||
|
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
|
||||||
|
"""Updating a room with a valid addons token should succeed."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(
|
||||||
|
users=[(user, RoleChoices.OWNER)],
|
||||||
|
access_level=RoomAccessLevel.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.patch(
|
||||||
|
f"/external-api/v1.0/rooms/{room.id}/",
|
||||||
|
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||||
|
mock_update_metadata.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_addons_token_inactive_user():
|
def test_api_rooms_addons_token_inactive_user():
|
||||||
"""Addons token for an inactive user should return 401."""
|
"""Addons token for an inactive user should return 401."""
|
||||||
user = UserFactory(is_active=False)
|
user = UserFactory(is_active=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user