Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37d54b5670 | |||
| 3887255e9c | |||
| 5d6ad3f3f6 | |||
| 44d68a9c80 | |||
| ed5c1bbd84 | |||
| f8c6da8021 | |||
| 5ba1657e00 | |||
| c28b8ba902 | |||
| 6962367e18 | |||
| 0bd57e8623 | |||
| 27f2023104 | |||
| 44362eca23 | |||
| c34a85699b | |||
| 12d8c4a9db | |||
| 42a05da5c0 | |||
| 4344dd6e35 | |||
| fe28902b2e | |||
| 1e1e1a2657 |
@@ -10,10 +10,17 @@ and this project adheres to
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) improve spinner reduced‑motion fallback #931
|
||||
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
||||
- 🥅(summary) catch file-related exceptions when handling recording #944
|
||||
- 📝(frontend) update legal terms #956
|
||||
- ⚡️(backend) enhance django admin's loading performance #954
|
||||
- 🌐(frontend) add missing DE translation for accessibility settings
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔐(backend) enforce object-level permission checks on room endpoint #959
|
||||
- 🔒️(backend) add application validation when consuming external JWT #963
|
||||
|
||||
## [1.5.0] - 2026-01-28
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import jwt as pyJwt
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from core.models import Application
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +96,18 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
logger.warning("Missing 'client_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
try:
|
||||
application = Application.objects.get(client_id=client_id)
|
||||
except Application.DoesNotExist as e:
|
||||
logger.warning("Application not found: %s", client_id)
|
||||
raise exceptions.AuthenticationFailed("Application not found.") from e
|
||||
|
||||
if not application.active:
|
||||
logger.warning(
|
||||
"Inactive application attempted authentication: %s", client_id
|
||||
)
|
||||
raise exceptions.AuthenticationFailed("Application is disabled.")
|
||||
|
||||
if not is_delegated:
|
||||
logger.warning("Token is not marked as delegated")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
@@ -33,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
Raises:
|
||||
PermissionDenied: If required scope is missing from token
|
||||
"""
|
||||
# Get the current action (e.g., 'list', 'create')
|
||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||
action = getattr(view, "action", None)
|
||||
if not action:
|
||||
raise exceptions.PermissionDenied(
|
||||
"Insufficient permissions. Unknown action."
|
||||
)
|
||||
# DRF routers return a 405 for unsupported methods
|
||||
return True
|
||||
|
||||
required_scope = self.scope_map.get(action)
|
||||
if not required_scope:
|
||||
@@ -57,9 +56,12 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
if isinstance(token_scopes, str):
|
||||
token_scopes = token_scopes.split()
|
||||
|
||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||
|
||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||
token_scopes = [
|
||||
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
|
||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||
for scope in token_scopes
|
||||
]
|
||||
|
||||
@@ -82,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
|
||||
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
||||
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
||||
}
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Allow access only to authenticated users."""
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
|
||||
user = request.user
|
||||
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return obj.has_any_role(user)
|
||||
|
||||
if request.method == "DELETE":
|
||||
return obj.is_owner(user)
|
||||
|
||||
return obj.is_administrator_or_owner(user)
|
||||
|
||||
@@ -182,7 +182,9 @@ class RoomViewSet(
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
||||
api.permissions.IsAuthenticated
|
||||
& permissions.HasRequiredRoomScope
|
||||
& permissions.RoomPermissions
|
||||
]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
@@ -292,6 +292,10 @@ class Resource(BaseModel):
|
||||
role = RoleChoices.MEMBER
|
||||
return role
|
||||
|
||||
def has_any_role(self, user):
|
||||
"""Check if a user has any role on the resource."""
|
||||
return self.get_role(user) is not None
|
||||
|
||||
def is_administrator_or_owner(self, user):
|
||||
"""
|
||||
Check if a user is administrator or owner of the resource."""
|
||||
|
||||
@@ -102,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = None
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
Tests for external API /room endpoint
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
# pylint: disable=W0621,C0302
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
import responses
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -27,12 +27,14 @@ def generate_test_token(user, scopes):
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
application = ApplicationFactory()
|
||||
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": "test-client-id",
|
||||
"client_id": str(application.client_id),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
@@ -53,6 +55,23 @@ def test_api_rooms_list_requires_authentication():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_list_inactive_user(settings):
|
||||
"""List should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_token(settings):
|
||||
"""Listing rooms with valid token should succeed."""
|
||||
|
||||
@@ -73,6 +92,24 @@ def test_api_rooms_list_with_valid_token(settings):
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_with_no_rooms(settings):
|
||||
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Generate valid token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 0
|
||||
assert response.data["results"] == []
|
||||
|
||||
|
||||
def test_api_rooms_list_with_expired_token(settings):
|
||||
"""Listing rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -92,8 +129,8 @@ def test_api_rooms_list_with_expired_token(settings):
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_with_invalid_token(settings):
|
||||
"""Listing rooms with invalid token should return 400."""
|
||||
def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
"""Listing rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
@@ -130,7 +167,27 @@ def test_api_rooms_list_missing_scope(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:list"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_no_scope(settings):
|
||||
"""Listing rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without scope
|
||||
token = generate_test_token(user, [])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_filters_by_user(settings):
|
||||
@@ -144,7 +201,9 @@ def test_api_rooms_list_filters_by_user(settings):
|
||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
token = generate_test_token(
|
||||
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
@@ -158,6 +217,84 @@ def test_api_rooms_list_filters_by_user(settings):
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_authentication(settings):
|
||||
"""Retrieving rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_inactive_user(settings):
|
||||
"""Retrieve should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_with_expired_token(settings):
|
||||
"""Retrieving rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Generate expired token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
||||
"""Retrieving rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": False,
|
||||
},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_scope(settings):
|
||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -178,6 +315,24 @@ def test_api_rooms_retrieve_requires_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_no_scope(settings):
|
||||
"""Retrieving rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without scope
|
||||
token = generate_test_token(user, [])
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success(settings):
|
||||
"""Retrieving a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -212,6 +367,132 @@ def test_api_rooms_retrieve_success(settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success_by_user(settings):
|
||||
"""Retrieve should only return rooms accessible to the authenticated user."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
||||
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
|
||||
|
||||
token = generate_test_token(
|
||||
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_not_found(settings):
|
||||
"""Retrieving a non-existing room with correct scope should return a 404."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "no room matches the given query." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_authentication(settings):
|
||||
"""Creating rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_create_with_expired_token(settings):
|
||||
"""Creating rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Generate expired token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_create_with_invalid_rs_token(settings):
|
||||
"""Creating rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": False,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_inactive_user(settings):
|
||||
"""Create should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_scope(settings):
|
||||
"""Creating a room requires ROOMS_CREATE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -225,18 +506,38 @@ def test_api_rooms_create_requires_scope(settings):
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:create" in str(
|
||||
response.data
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:create"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_no_scope(settings):
|
||||
"""Creating rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without scope
|
||||
token = generate_test_token(user, [])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_success(settings):
|
||||
"""Creating a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
@@ -245,6 +546,8 @@ def test_api_rooms_create_success(settings):
|
||||
assert response.status_code == 201
|
||||
assert "id" in response.data
|
||||
assert "slug" in response.data
|
||||
assert "name" in response.data
|
||||
assert response.data["name"] == response.data["slug"]
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
@@ -252,6 +555,72 @@ def test_api_rooms_create_success(settings):
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_create_readonly_enforcement(settings):
|
||||
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{
|
||||
"id": "fake-id",
|
||||
"slug": "fake-slug",
|
||||
"name": "fake-name",
|
||||
"access_level": "public",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert "slug" in response.data
|
||||
assert response.data["id"] != "fake-id"
|
||||
assert "name" in response.data
|
||||
assert response.data["slug"] != "fake-slug"
|
||||
assert "id" in response.data
|
||||
assert response.data["name"] != "fake-name"
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_unknown_actions(settings):
|
||||
"""Updating or deleting a room are not supported yet."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(
|
||||
user,
|
||||
[
|
||||
ApplicationScope.ROOMS_RETRIEVE,
|
||||
ApplicationScope.ROOMS_DELETE,
|
||||
ApplicationScope.ROOMS_UPDATE,
|
||||
],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert 'method "delete" not allowed.' in str(response.data).lower()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert 'method "patch" not allowed.' in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -290,10 +659,43 @@ def test_api_rooms_response_no_telephony(settings):
|
||||
assert response.data["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_token_scope_case_insensitive(settings):
|
||||
"""Token's scope should be case-insensitive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
# Generate token with mixed-case scope "Rooms:List" to verify that scope
|
||||
# validation is case-insensitive (should match "rooms:list")
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"""Token without delegated flag should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -302,7 +704,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "test-client",
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": False, # Not delegated
|
||||
@@ -318,7 +720,73 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token type." in str(response.data)
|
||||
assert "invalid token type." in str(response.data).lower()
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid key should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
"invalid-private-key",
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid alg should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_ALG = "RS256"
|
||||
user = UserFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "test-client",
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm="HS256", # different value
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_token_missing_client_id(settings):
|
||||
@@ -348,7 +816,157 @@ def test_api_rooms_token_missing_client_id(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token claims." in str(response.data)
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_missing_user_id(settings):
|
||||
"""Token without user_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
# Missing user_id
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_invalid_audience(settings):
|
||||
"""Token with an invalid audience should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": "invalid-audience",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"user_id": str(user.id),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_unknown_user(settings):
|
||||
"""Token for unknown user should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user not found." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_unknown_application(settings):
|
||||
"""Token for unknown application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "unknown-client-id",
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application not found." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_inactive_application(settings):
|
||||
"""Token for inactive application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory(active=False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application is disabled." in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@@ -507,7 +1125,7 @@ def test_resource_server_authentication_successful(settings):
|
||||
"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:list",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -108,12 +108,18 @@
|
||||
"label": "Sprache"
|
||||
},
|
||||
"settingsButtonLabel": "Einstellungen",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Reaktionen vorlesen"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"accessibility": "Barrierefreiheit",
|
||||
"transcription": "Transkription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ProgressBar } from 'react-aria-components'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiHourglassFill } from '@remixicon/react'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
|
||||
const rotatingArcClassName = css({
|
||||
animation: 'rotate 1s ease-in-out infinite',
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 16ms linear',
|
||||
})
|
||||
|
||||
export const Spinner = ({
|
||||
size = 56,
|
||||
@@ -13,44 +20,77 @@ export const Spinner = ({
|
||||
const r = 14 - strokeWidth
|
||||
const c = 2 * r * Math.PI
|
||||
return (
|
||||
<ProgressBar aria-label="Loading…" value={30}>
|
||||
<ProgressBar aria-label="Loading..." value={30}>
|
||||
{({ percentage }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
<div
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={r}
|
||||
strokeDasharray={0}
|
||||
strokeDashoffset={0}
|
||||
strokeLinecap="round"
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
className={css({
|
||||
stroke: variant == 'light' ? 'primary.100' : 'transparent',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
display: 'none',
|
||||
},
|
||||
})}
|
||||
style={{}}
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={r}
|
||||
strokeDasharray={`${c} ${c}`}
|
||||
strokeDashoffset={percentage && c - (percentage / 100) * c}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={r}
|
||||
strokeDasharray={0}
|
||||
strokeDashoffset={0}
|
||||
strokeLinecap="round"
|
||||
className={css({
|
||||
stroke: variant == 'light' ? 'primary.100' : 'transparent',
|
||||
})}
|
||||
/>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={r}
|
||||
strokeDasharray={`${c} ${c}`}
|
||||
strokeDashoffset={
|
||||
typeof percentage === 'number'
|
||||
? c - (percentage / 100) * c
|
||||
: undefined
|
||||
}
|
||||
strokeLinecap="round"
|
||||
className={cx(
|
||||
rotatingArcClassName,
|
||||
css({
|
||||
stroke: variant == 'light' ? 'primary.800' : 'white',
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
stroke: variant == 'light' ? 'primary.800' : 'white',
|
||||
display: 'none',
|
||||
color: 'black',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
display: 'inline-flex',
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
animation: `rotate 1s ease-in-out infinite`,
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 16ms linear',
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
>
|
||||
<RiHourglassFill
|
||||
size={Math.max(16, Math.round(size * 0.4))}
|
||||
style={{
|
||||
display: 'block',
|
||||
transform: 'translateY(1px)',
|
||||
color: variant == 'light' ? 'primary.800' : 'white',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ProgressBar>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user