Files
chainlit--chainlit/backend/tests/test_socket.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:48:47 +08:00

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