b7f52be4c9
CI / Run CI (push) Has been cancelled
CI / check-backend (push) Has been cancelled
CI / check-frontend (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / e2e-tests (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
631 lines
22 KiB
Python
631 lines
22 KiB
Python
import json
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from chainlit.session import WebsocketSession
|
|
from chainlit.socket import (
|
|
_authenticate_connection,
|
|
_get_token,
|
|
_get_token_from_cookie,
|
|
clean_session,
|
|
connection_successful,
|
|
load_user_env,
|
|
persist_user_session,
|
|
restore_existing_session,
|
|
resume_thread,
|
|
)
|
|
|
|
|
|
class TestGetTokenFromCookie:
|
|
"""Test suite for _get_token_from_cookie function."""
|
|
|
|
def test_get_token_from_cookie_with_valid_cookie(self):
|
|
"""Test extracting token from valid cookie header."""
|
|
with patch("chainlit.socket.get_token_from_cookies") as mock_get_token:
|
|
mock_get_token.return_value = "test_token"
|
|
environ = {"HTTP_COOKIE": "session=abc123; token=test_token"}
|
|
|
|
result = _get_token_from_cookie(environ)
|
|
|
|
assert result == "test_token"
|
|
mock_get_token.assert_called_once()
|
|
|
|
def test_get_token_from_cookie_without_cookie(self):
|
|
"""Test when no cookie header is present."""
|
|
environ = {}
|
|
result = _get_token_from_cookie(environ)
|
|
assert result is None
|
|
|
|
def test_get_token_from_cookie_with_empty_cookie(self):
|
|
"""Test with empty cookie header."""
|
|
with patch("chainlit.socket.get_token_from_cookies") as mock_get_token:
|
|
mock_get_token.return_value = None
|
|
environ = {"HTTP_COOKIE": ""}
|
|
|
|
result = _get_token_from_cookie(environ)
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestGetToken:
|
|
"""Test suite for _get_token function."""
|
|
|
|
def test_get_token_calls_get_token_from_cookie(self):
|
|
"""Test that _get_token delegates to _get_token_from_cookie."""
|
|
with patch("chainlit.socket._get_token_from_cookie") as mock_get_cookie:
|
|
mock_get_cookie.return_value = "token_value"
|
|
environ = {"HTTP_COOKIE": "token=token_value"}
|
|
|
|
result = _get_token(environ)
|
|
|
|
assert result == "token_value"
|
|
mock_get_cookie.assert_called_once_with(environ)
|
|
|
|
|
|
class TestAuthenticateConnection:
|
|
"""Test suite for _authenticate_connection function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_connection_with_valid_token(self):
|
|
"""Test authentication with valid token."""
|
|
mock_user = Mock()
|
|
mock_user.identifier = "user123"
|
|
|
|
with patch("chainlit.socket._get_token") as mock_get_token:
|
|
with patch("chainlit.socket.get_current_user") as mock_get_user:
|
|
mock_get_token.return_value = "valid_token"
|
|
mock_get_user.return_value = mock_user
|
|
|
|
environ = {"HTTP_COOKIE": "token=valid_token"}
|
|
user, token = await _authenticate_connection(environ)
|
|
|
|
assert user == mock_user
|
|
assert token == "valid_token"
|
|
mock_get_user.assert_called_once_with(token="valid_token")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_connection_without_token(self):
|
|
"""Test authentication without token."""
|
|
with patch("chainlit.socket._get_token") as mock_get_token:
|
|
mock_get_token.return_value = None
|
|
|
|
environ = {}
|
|
user, token = await _authenticate_connection(environ)
|
|
|
|
assert user is None
|
|
assert token is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_connection_with_invalid_token(self):
|
|
"""Test authentication with invalid token."""
|
|
with patch("chainlit.socket._get_token") as mock_get_token:
|
|
with patch("chainlit.socket.get_current_user") as mock_get_user:
|
|
mock_get_token.return_value = "invalid_token"
|
|
mock_get_user.return_value = None
|
|
|
|
environ = {"HTTP_COOKIE": "token=invalid_token"}
|
|
user, token = await _authenticate_connection(environ)
|
|
|
|
assert user is None
|
|
assert token is None
|
|
|
|
|
|
class TestRestoreExistingSession:
|
|
"""Test suite for restore_existing_session function."""
|
|
|
|
def test_restore_existing_session_success(self):
|
|
"""Test restoring an existing session."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = None
|
|
emit_fn = Mock()
|
|
emit_call_fn = Mock()
|
|
environ = {"HTTP_COOKIE": "token=token"}
|
|
|
|
with patch.object(WebsocketSession, "get_by_id") as mock_get:
|
|
mock_get.return_value = mock_session
|
|
|
|
result = restore_existing_session(
|
|
"new_sid", "session_123", emit_fn, emit_call_fn, environ
|
|
)
|
|
|
|
assert result is True
|
|
mock_session.restore.assert_called_once_with(new_socket_id="new_sid")
|
|
assert mock_session.emit == emit_fn
|
|
assert mock_session.emit_call == emit_call_fn
|
|
assert mock_session.environ == environ
|
|
|
|
def test_restore_existing_session_with_matching_user(self):
|
|
"""Test restoring a session for its authenticated owner."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="user123")
|
|
authenticated_user = Mock(identifier="user123")
|
|
|
|
with patch.object(WebsocketSession, "get_by_id") as mock_get:
|
|
mock_get.return_value = mock_session
|
|
|
|
result = restore_existing_session(
|
|
"new_sid",
|
|
"session_123",
|
|
Mock(),
|
|
Mock(),
|
|
{"HTTP_COOKIE": "token=token"},
|
|
user=authenticated_user,
|
|
)
|
|
|
|
assert result is True
|
|
mock_session.restore.assert_called_once_with(new_socket_id="new_sid")
|
|
|
|
def test_restore_existing_session_rejects_different_user(self):
|
|
"""Test that a session cannot be restored by another user."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="victim")
|
|
authenticated_user = Mock(identifier="attacker")
|
|
|
|
with patch.object(WebsocketSession, "get_by_id") as mock_get:
|
|
mock_get.return_value = mock_session
|
|
|
|
with pytest.raises(ConnectionRefusedError, match="authorization failed"):
|
|
restore_existing_session(
|
|
"new_sid",
|
|
"session_123",
|
|
Mock(),
|
|
Mock(),
|
|
{"HTTP_COOKIE": "token=token"},
|
|
user=authenticated_user,
|
|
)
|
|
|
|
mock_session.restore.assert_not_called()
|
|
|
|
def test_restore_existing_session_not_found(self):
|
|
"""Test when session is not found."""
|
|
with patch.object(WebsocketSession, "get_by_id") as mock_get:
|
|
mock_get.return_value = None
|
|
|
|
result = restore_existing_session(
|
|
"new_sid", "session_123", Mock(), Mock(), {"HTTP_COOKIE": "token=token"}
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestPersistUserSession:
|
|
"""Test suite for persist_user_session function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_user_session_with_data_layer(self):
|
|
"""Test persisting user session with data layer."""
|
|
mock_data_layer = AsyncMock()
|
|
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
metadata = {"key": "value"}
|
|
await persist_user_session("thread_123", metadata)
|
|
|
|
mock_data_layer.update_thread.assert_called_once_with(
|
|
thread_id="thread_123", metadata=metadata
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_user_session_without_data_layer(self):
|
|
"""Test persisting when no data layer is available."""
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = None
|
|
|
|
# Should not raise an error
|
|
await persist_user_session("thread_123", {"key": "value"})
|
|
|
|
|
|
class TestResumeThread:
|
|
"""Test suite for resume_thread function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_without_data_layer(self):
|
|
"""Test resume thread when no data layer exists."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock()
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = None
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_without_user(self):
|
|
"""Test resume thread when session has no user."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = None
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_without_thread_id(self):
|
|
"""Test resume thread when no thread_id_to_resume."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock()
|
|
mock_session.thread_id_to_resume = None
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_thread_not_found(self):
|
|
"""Test resume thread when thread doesn't exist."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="user123")
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
mock_session.id = "session_123"
|
|
|
|
mock_data_layer = AsyncMock()
|
|
mock_data_layer.get_thread.return_value = None
|
|
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result is None
|
|
mock_data_layer.get_thread.assert_called_once_with(thread_id="thread_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_user_not_author(self):
|
|
"""Test resume thread when user is not the thread author."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="user123")
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
mock_session.id = "session_123"
|
|
|
|
thread = {"userIdentifier": "different_user", "metadata": {}}
|
|
mock_data_layer = AsyncMock()
|
|
mock_data_layer.get_thread.return_value = thread
|
|
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_success(self):
|
|
"""Test successful thread resumption."""
|
|
from chainlit.user_session import user_sessions
|
|
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="user123")
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
mock_session.id = "session_123"
|
|
|
|
metadata = {
|
|
"chat_profile": "gpt-4",
|
|
"chat_settings": {"temperature": 0.7},
|
|
}
|
|
thread = {"userIdentifier": "user123", "metadata": metadata}
|
|
|
|
mock_data_layer = AsyncMock()
|
|
mock_data_layer.get_thread.return_value = thread
|
|
|
|
original_sessions = user_sessions.copy()
|
|
try:
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result == thread
|
|
assert mock_session.chat_profile == "gpt-4"
|
|
assert mock_session.chat_settings == {"temperature": 0.7}
|
|
assert user_sessions.get("session_123") == metadata
|
|
finally:
|
|
user_sessions.clear()
|
|
user_sessions.update(original_sessions)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_with_string_metadata(self):
|
|
"""Test thread resumption with JSON string metadata."""
|
|
from chainlit.user_session import user_sessions
|
|
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="user123")
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
mock_session.id = "session_123"
|
|
|
|
metadata_dict = {"chat_profile": "gpt-4"}
|
|
thread = {
|
|
"userIdentifier": "user123",
|
|
"metadata": json.dumps(metadata_dict),
|
|
}
|
|
|
|
mock_data_layer = AsyncMock()
|
|
mock_data_layer.get_thread.return_value = thread
|
|
|
|
original_sessions = user_sessions.copy()
|
|
try:
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result == thread
|
|
assert mock_session.chat_profile == "gpt-4"
|
|
finally:
|
|
user_sessions.clear()
|
|
user_sessions.update(original_sessions)
|
|
|
|
|
|
class TestLoadUserEnv:
|
|
"""Test suite for load_user_env function."""
|
|
|
|
def test_load_user_env_with_valid_json(self):
|
|
"""Test loading valid user environment JSON."""
|
|
user_env = '{"API_KEY": "secret", "ENV_VAR": "value"}'
|
|
|
|
with patch("chainlit.socket.config") as mock_config:
|
|
mock_config.project.user_env = []
|
|
|
|
result = load_user_env(user_env)
|
|
|
|
assert result == {"API_KEY": "secret", "ENV_VAR": "value"}
|
|
|
|
def test_load_user_env_with_required_keys(self):
|
|
"""Test loading user env with required keys."""
|
|
user_env = '{"API_KEY": "secret", "OTHER_KEY": "value"}'
|
|
|
|
with patch("chainlit.socket.config") as mock_config:
|
|
mock_config.project.user_env = ["API_KEY", "OTHER_KEY"]
|
|
|
|
result = load_user_env(user_env)
|
|
|
|
assert result == {"API_KEY": "secret", "OTHER_KEY": "value"}
|
|
|
|
def test_load_user_env_missing_required_key(self):
|
|
"""Test error when required key is missing."""
|
|
user_env = '{"API_KEY": "secret"}'
|
|
|
|
with patch("chainlit.socket.config") as mock_config:
|
|
mock_config.project.user_env = ["API_KEY", "MISSING_KEY"]
|
|
|
|
with pytest.raises(
|
|
ConnectionRefusedError, match="Missing user environment variable"
|
|
):
|
|
load_user_env(user_env)
|
|
|
|
def test_load_user_env_none_with_required_keys(self):
|
|
"""Test error when user_env is None but keys are required."""
|
|
with patch("chainlit.socket.config") as mock_config:
|
|
mock_config.project.user_env = ["API_KEY"]
|
|
|
|
with pytest.raises(
|
|
ConnectionRefusedError, match="Missing user environment variables"
|
|
):
|
|
load_user_env(None)
|
|
|
|
def test_load_user_env_none_without_required_keys(self):
|
|
"""Test when user_env is None and no keys are required."""
|
|
with patch("chainlit.socket.config") as mock_config:
|
|
mock_config.project.user_env = []
|
|
|
|
result = load_user_env(None)
|
|
|
|
assert result == {}
|
|
|
|
|
|
class TestCleanSession:
|
|
"""Test suite for clean_session function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clean_session_with_existing_session(self):
|
|
"""Test marking session for cleanup."""
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.to_clear = False
|
|
|
|
with patch.object(WebsocketSession, "get") as mock_get:
|
|
mock_get.return_value = mock_session
|
|
|
|
await clean_session("socket_123")
|
|
|
|
assert mock_session.to_clear is True
|
|
mock_get.assert_called_once_with("socket_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clean_session_without_session(self):
|
|
"""Test clean_session when session doesn't exist."""
|
|
with patch.object(WebsocketSession, "get") as mock_get:
|
|
mock_get.return_value = None
|
|
|
|
# Should not raise an error
|
|
await clean_session("socket_123")
|
|
|
|
|
|
class TestSocketEdgeCases:
|
|
"""Test suite for socket edge cases."""
|
|
|
|
def test_restore_existing_session_with_none_session_id(self):
|
|
"""Test restore with None session_id."""
|
|
with patch.object(WebsocketSession, "get_by_id") as mock_get:
|
|
mock_get.return_value = None
|
|
|
|
result = restore_existing_session(None, None, Mock(), Mock(), None)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_user_session_with_empty_metadata(self):
|
|
"""Test persisting empty metadata."""
|
|
mock_data_layer = AsyncMock()
|
|
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
await persist_user_session("thread_123", {})
|
|
|
|
mock_data_layer.update_thread.assert_called_once_with(
|
|
thread_id="thread_123", metadata={}
|
|
)
|
|
|
|
def test_load_user_env_with_empty_json(self):
|
|
"""Test loading empty user environment."""
|
|
user_env = "{}"
|
|
|
|
with patch("chainlit.socket.config") as mock_config:
|
|
mock_config.project.user_env = []
|
|
|
|
result = load_user_env(user_env)
|
|
|
|
assert result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_thread_with_empty_metadata(self):
|
|
"""Test resuming thread with empty metadata."""
|
|
from chainlit.user_session import user_sessions
|
|
|
|
mock_session = Mock(spec=WebsocketSession)
|
|
mock_session.user = Mock(identifier="user123")
|
|
mock_session.thread_id_to_resume = "thread_123"
|
|
mock_session.id = "session_123"
|
|
|
|
thread = {"userIdentifier": "user123", "metadata": {}}
|
|
|
|
mock_data_layer = AsyncMock()
|
|
mock_data_layer.get_thread.return_value = thread
|
|
|
|
original_sessions = user_sessions.copy()
|
|
try:
|
|
with patch("chainlit.socket.get_data_layer") as mock_get_dl:
|
|
mock_get_dl.return_value = mock_data_layer
|
|
|
|
result = await resume_thread(mock_session)
|
|
|
|
assert result == thread
|
|
assert user_sessions.get("session_123") == {}
|
|
finally:
|
|
user_sessions.clear()
|
|
user_sessions.update(original_sessions)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_connection_with_exception(self):
|
|
"""Test authentication when get_current_user raises exception."""
|
|
with patch("chainlit.socket._get_token") as mock_get_token:
|
|
with patch("chainlit.socket.get_current_user") as mock_get_user:
|
|
mock_get_token.return_value = "token"
|
|
mock_get_user.side_effect = Exception("Auth error")
|
|
|
|
environ = {"HTTP_COOKIE": "token=token"}
|
|
|
|
# Should propagate the exception
|
|
with pytest.raises(Exception, match="Auth error"):
|
|
await _authenticate_connection(environ)
|
|
|
|
|
|
class TestConnectionSuccessfulIdempotency:
|
|
"""Regression tests: on_chat_start must fire exactly once per
|
|
WebsocketSession, even when connection_successful is dispatched multiple
|
|
times (Socket.IO reconnect, React StrictMode double-mount, reverse-proxy
|
|
WS 101 retry).
|
|
|
|
Refs chainlit#2535, chainlit#2549, chainlit#2228.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_chat_start_not_duplicated_on_reconnect(
|
|
self, mock_session_factory
|
|
):
|
|
"""Reconnect path (session.restored=True): on_chat_start scheduled once."""
|
|
on_chat_start = AsyncMock()
|
|
|
|
session = mock_session_factory(has_first_interaction=False)
|
|
session.restored = True
|
|
session.chat_started = False
|
|
session.current_task = None
|
|
session.thread_id_to_resume = None
|
|
|
|
mock_context = Mock()
|
|
mock_context.session = session
|
|
mock_context.emitter = AsyncMock()
|
|
|
|
mock_config = Mock()
|
|
mock_config.code.on_chat_start = on_chat_start
|
|
mock_config.code.on_chat_resume = None
|
|
|
|
with (
|
|
patch("chainlit.socket.init_ws_context", return_value=mock_context),
|
|
patch("chainlit.socket.config", mock_config),
|
|
):
|
|
await connection_successful("sid-1")
|
|
# Simulate reconnect: same session object, chat_started now True.
|
|
await connection_successful("sid-1")
|
|
|
|
assert on_chat_start.call_count == 1, (
|
|
"on_chat_start must be scheduled exactly once per WebsocketSession"
|
|
)
|
|
assert session.chat_started is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_chat_start_fires_once_on_fresh_session(
|
|
self, mock_session_factory
|
|
):
|
|
"""Normal one-connect path still greets exactly once."""
|
|
on_chat_start = AsyncMock()
|
|
|
|
session = mock_session_factory(has_first_interaction=False)
|
|
session.restored = False
|
|
session.chat_started = False
|
|
session.current_task = None
|
|
session.thread_id_to_resume = None
|
|
|
|
mock_context = Mock()
|
|
mock_context.session = session
|
|
mock_context.emitter = AsyncMock()
|
|
|
|
mock_config = Mock()
|
|
mock_config.code.on_chat_start = on_chat_start
|
|
mock_config.code.on_chat_resume = None
|
|
|
|
with (
|
|
patch("chainlit.socket.init_ws_context", return_value=mock_context),
|
|
patch("chainlit.socket.config", mock_config),
|
|
):
|
|
await connection_successful("sid-1")
|
|
|
|
assert on_chat_start.call_count == 1
|
|
assert session.chat_started is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_chat_start_not_duplicated_on_fresh_then_reconnect(
|
|
self, mock_session_factory
|
|
):
|
|
"""Fresh connect followed by a reconnect still fires exactly once."""
|
|
on_chat_start = AsyncMock()
|
|
|
|
session = mock_session_factory(has_first_interaction=False)
|
|
session.restored = False
|
|
session.chat_started = False
|
|
session.current_task = None
|
|
session.thread_id_to_resume = None
|
|
|
|
mock_context = Mock()
|
|
mock_context.session = session
|
|
mock_context.emitter = AsyncMock()
|
|
|
|
mock_config = Mock()
|
|
mock_config.code.on_chat_start = on_chat_start
|
|
mock_config.code.on_chat_resume = None
|
|
|
|
with (
|
|
patch("chainlit.socket.init_ws_context", return_value=mock_context),
|
|
patch("chainlit.socket.config", mock_config),
|
|
):
|
|
await connection_successful("sid-1")
|
|
session.restored = True
|
|
await connection_successful("sid-1")
|
|
|
|
assert on_chat_start.call_count == 1
|