diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index 4b5173f9..00071585 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -2,15 +2,18 @@ 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 ( @@ -53,6 +56,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 +93,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 +130,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 +168,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 +202,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 +218,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 +316,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" @@ -224,7 +380,9 @@ def test_api_rooms_retrieve_success_by_user(settings): room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)]) room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)]) - token = generate_test_token(user1, [ApplicationScope.ROOMS_RETRIEVE]) + token = generate_test_token( + user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST] + ) client = APIClient() client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") @@ -251,6 +409,91 @@ def test_api_rooms_retrieve_success_by_user(settings): 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" @@ -264,18 +507,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}") @@ -284,6 +547,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"]) @@ -291,6 +556,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 == 403 + assert "insufficient permissions. unknown action." 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 == 403 + assert "insufficient permissions. unknown action." 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" @@ -357,7 +688,72 @@ 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() + + # 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, + "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): @@ -387,7 +783,95 @@ 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" + + 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", + "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() + + now = datetime.now(timezone.utc) + payload = { + "iss": settings.APPLICATION_JWT_ISSUER, + "aud": "invalid-audience", + "iat": now, + "exp": now + timedelta(hours=1), + "client_id": "test-client", + "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" + + 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", + "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() @responses.activate @@ -546,7 +1030,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, }, )