diff --git a/src/backend/chat/tests/conftest.py b/src/backend/chat/tests/conftest.py index 93107f0..0d2bbc0 100644 --- a/src/backend/chat/tests/conftest.py +++ b/src/backend/chat/tests/conftest.py @@ -2,12 +2,18 @@ import logging from contextlib import ExitStack, contextmanager +from importlib import reload from unittest.mock import patch +from django.urls import clear_url_caches, set_urlconf from django.utils import formats, timezone import pytest +import core.urls + +import chat.views +import conversations.urls from chat.agents.summarize import SummarizationAgent from chat.clients.pydantic_ai import AIAgentService @@ -90,3 +96,35 @@ PIXEL_PNG = ( b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe" b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82" ) + + +@pytest.fixture(name="oidc_refresh_token_enabled") +def fixture_oidc_refresh_token_enabled(settings): + """ + Fixture to enable OIDC refresh token storage during the test. + + This is not a nice fixture, as it forces reloading views and URL configurations. + Maybe there is a better way to do this, because even the conditional_refresh_oidc_token + function is not nice, but I don't want it to be lazy either. + """ + __initial_value = bool(settings.OIDC_STORE_REFRESH_TOKEN) + + settings.OIDC_STORE_REFRESH_TOKEN = True + + # force view reload + reload(chat.views) + reload(core.urls) + reload(conversations.urls) + clear_url_caches() + set_urlconf(None) + + yield settings + + settings.OIDC_STORE_REFRESH_TOKEN = __initial_value + + # force view reload + reload(chat.views) + reload(core.urls) + reload(conversations.urls) + clear_url_caches() + set_urlconf(None) diff --git a/src/backend/chat/tests/views/chat/conftest.py b/src/backend/chat/tests/views/chat/conftest.py deleted file mode 100644 index d4be3b6..0000000 --- a/src/backend/chat/tests/views/chat/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Common test fixtures for chat views tests.""" - -from unittest import mock - -import pytest - - -@pytest.fixture(autouse=True) -def mock_process_request(): - """ - Mock process_request to bypass OIDC authentication in tests. - """ - with mock.patch( - "lasuite.oidc_login.decorators.RefreshOIDCAccessToken.process_request" - ) as mocked_process_request: - mocked_process_request.return_value = None - yield mocked_process_request diff --git a/src/backend/chat/tests/views/chat/conversations/test_conversation.py b/src/backend/chat/tests/views/chat/conversations/test_conversation.py index 0c779c5..27378d3 100644 --- a/src/backend/chat/tests/views/chat/conversations/test_conversation.py +++ b/src/backend/chat/tests/views/chat/conversations/test_conversation.py @@ -3,6 +3,7 @@ import json import logging +import time from unittest.mock import ANY, patch from django.utils import timezone @@ -1612,3 +1613,106 @@ async def test_post_conversation_async_triggers_keepalive( "run_id": _run_id, }, ] + + +def test_post_conversation_oidc_refresh_enabled_unrefreshed( # pylint: disable=unused-argument + api_client, oidc_refresh_token_enabled +): + """Test posting messages to a conversation without fresh access token should be forbidden.""" + chat_conversation = ChatConversationFactory(owner__language="en-us") + + url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data" + data = { + "messages": [ + { + "id": "yuPoOuBkKA4FnKvk", + "role": "user", + "parts": [{"text": "Hello", "type": "text"}], + "content": "Hello", + "createdAt": "2025-07-03T15:22:17.105Z", + } + ] + } + api_client.force_login( + chat_conversation.owner, backend="core.authentication.backends.OIDCAuthenticationBackend" + ) + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@freeze_time("2025-07-25T10:36:35.297675Z") +@respx.mock +def test_post_conversation_oidc_refresh_enabled( # pylint: disable=unused-argument + api_client, mock_openai_stream, oidc_refresh_token_enabled +): + """Test posting messages to a conversation using the 'data' protocol.""" + chat_conversation = ChatConversationFactory(owner__language="en-us") + + url = f"/api/v1.0/chats/{chat_conversation.pk}/conversation/?protocol=data" + data = { + "messages": [ + { + "id": "yuPoOuBkKA4FnKvk", + "role": "user", + "parts": [{"text": "Hello", "type": "text"}], + "content": "Hello", + "createdAt": "2025-07-03T15:22:17.105Z", + } + ] + } + api_client.force_login( + chat_conversation.owner, backend="core.authentication.backends.OIDCAuthenticationBackend" + ) + session = api_client.session + + session["oidc_id_token_expiration"] = time.time() + 3600 # valid for 1 hour + session["oidc_token_expiration"] = session["oidc_id_token_expiration"] # ... + session.save() + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.get("Content-Type") == "text/event-stream" + assert response.get("x-vercel-ai-data-stream") == "v1" + assert response.streaming + + # Wait for the streaming content to be fully received + response_content = b"".join(response.streaming_content).decode("utf-8") + + # Replace UUIDs with placeholders for assertion + response_content = replace_uuids_with_placeholder(response_content) + + assert response_content == ( + '0:"Hello"\n' + '0:" there"\n' + 'f:{"messageId":""}\n' + 'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n' + ) + + assert mock_openai_stream.called + + # ensure instructions are merged as a system prompt + last_request_payload = json.loads(respx.calls.last.request.content) + assert last_request_payload["messages"][0] == { + "content": ( + "You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\n" + "Answer in english." + ), + "role": "system", + } + + chat_conversation.refresh_from_db() + assert chat_conversation.ui_messages == [ + { + "content": "Hello", + "createdAt": "2025-07-03T15:22:17.105Z", + "id": "yuPoOuBkKA4FnKvk", + "parts": [{"text": "Hello", "type": "text"}], + "role": "user", + } + ] + + assert len(chat_conversation.messages) == 2 + assert len(chat_conversation.pydantic_messages) == 2 diff --git a/src/backend/chat/views.py b/src/backend/chat/views.py index e2ec47d..c9f63c1 100644 --- a/src/backend/chat/views.py +++ b/src/backend/chat/views.py @@ -37,6 +37,19 @@ from chat.serializers import ChatConversationRequestSerializer logger = logging.getLogger(__name__) +def conditional_refresh_oidc_token(func): + """ + Conditionally apply refresh_oidc_access_token decorator. + + The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning + we can actually refresh something. Broader settings checks are done in settings.py. + """ + if settings.OIDC_STORE_REFRESH_TOKEN: + return method_decorator(refresh_oidc_access_token)(func) + + return func + + class ChatConversationFilter(filters.BaseFilterBackend): """Filter conversation.""" @@ -128,7 +141,7 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method self.permission_classes = [] return super().get_permissions() - @method_decorator(refresh_oidc_access_token) + @conditional_refresh_oidc_token @decorators.action( methods=["post"], detail=True, diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index c416f59..5eb49b7 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -1096,6 +1096,24 @@ USER QUESTION: "Please set FILE_BACKEND_URL to a valid URL for backend temporary file access." ) + # Find configuration + if ( + cls.RAG_DOCUMENT_SEARCH_BACKEND + == "chat.agent_rag.document_rag_backends.find_rag_backend.FindRagBackend" + and not all( + ( + cls.FIND_API_KEY, + cls.FIND_API_URL, + cls.OIDC_STORE_ACCESS_TOKEN, + cls.OIDC_STORE_REFRESH_TOKEN, + ) + ) + ): + raise ValueError( + f"{cls.RAG_DOCUMENT_SEARCH_BACKEND} requires FIND_API_KEY, FIND_API_URL, " + "OIDC_STORE_ACCESS_TOKEN and OIDC_STORE_REFRESH_TOKEN to be set." + ) + # Langfuse initialization if cls.LANGFUSE_ENABLED: if not cls.LANGFUSE_MEDIA_UPLOAD_ENABLED: