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
1155 lines
36 KiB
Python
1155 lines
36 KiB
Python
import datetime
|
|
import os
|
|
import pathlib
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
from unittest.mock import AsyncMock, Mock, create_autospec, mock_open
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from chainlit.auth import get_current_user
|
|
from chainlit.config import (
|
|
APP_ROOT,
|
|
ChainlitConfig,
|
|
SpontaneousFileUploadFeature,
|
|
)
|
|
from chainlit.server import app
|
|
from chainlit.types import AskFileSpec
|
|
from chainlit.user import PersistedUser
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client():
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_load_translation(test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch):
|
|
mock_method = Mock(return_value={"key": "value"})
|
|
monkeypatch.setattr("chainlit.config.ChainlitConfig.load_translation", mock_method)
|
|
|
|
return mock_method
|
|
|
|
|
|
def test_project_translations_default_language(
|
|
test_client: TestClient, mock_load_translation: Mock
|
|
):
|
|
"""Test with default language."""
|
|
response = test_client.get("/project/translations")
|
|
assert response.status_code == 200
|
|
assert "translation" in response.json()
|
|
mock_load_translation.assert_called_once_with("en-US")
|
|
mock_load_translation.reset_mock()
|
|
|
|
|
|
def test_project_translations_specific_language(
|
|
test_client: TestClient, mock_load_translation: Mock
|
|
):
|
|
"""Test with a specific language."""
|
|
|
|
response = test_client.get("/project/translations?language=fr-FR")
|
|
assert response.status_code == 200
|
|
assert "translation" in response.json()
|
|
mock_load_translation.assert_called_once_with("fr-FR")
|
|
mock_load_translation.reset_mock()
|
|
|
|
|
|
def test_project_translations_invalid_language(
|
|
test_client: TestClient, mock_load_translation: Mock
|
|
):
|
|
"""Test with an invalid language."""
|
|
|
|
response = test_client.get("/project/translations?language=invalid")
|
|
assert response.status_code == 422
|
|
|
|
assert (
|
|
"translation" not in response.json()
|
|
) # It should fall back to default translation
|
|
assert not mock_load_translation.called
|
|
|
|
|
|
def test_project_translations_bcp47_language(
|
|
test_client: TestClient, mock_load_translation: Mock
|
|
):
|
|
"""Regression test for https://github.com/Chainlit/chainlit/issues/1352."""
|
|
|
|
response = test_client.get("/project/translations?language=es-419")
|
|
assert response.status_code == 200
|
|
assert "translation" in response.json()
|
|
mock_load_translation.assert_called_once_with("es-419")
|
|
mock_load_translation.reset_mock()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_get_current_user():
|
|
"""Override get_current_user() dependency."""
|
|
|
|
# Programming sucks!
|
|
# Ref: https://github.com/fastapi/fastapi/issues/3331#issuecomment-1182452859
|
|
app.dependency_overrides[get_current_user] = create_autospec(lambda: None)
|
|
|
|
yield app.dependency_overrides[get_current_user]
|
|
|
|
del app.dependency_overrides[get_current_user]
|
|
|
|
|
|
async def test_project_settings(test_client: TestClient, mock_get_current_user: Mock):
|
|
"""Burn test for project settings."""
|
|
response = test_client.get(
|
|
"/project/settings",
|
|
)
|
|
|
|
mock_get_current_user.assert_called_once()
|
|
|
|
assert response.status_code == 200, response.json()
|
|
data = response.json()
|
|
|
|
assert "ui" in data
|
|
assert "features" in data
|
|
assert "userEnv" in data
|
|
assert "dataPersistence" in data
|
|
assert "threadResumable" in data
|
|
assert "markdown" in data
|
|
assert "debugUrl" in data
|
|
assert data["chatProfiles"] == []
|
|
assert data["starters"] == []
|
|
|
|
|
|
def test_project_settings_path_traversal(
|
|
test_client: TestClient,
|
|
mock_get_current_user: Mock,
|
|
tmp_path: Path,
|
|
test_config: ChainlitConfig,
|
|
):
|
|
"""Test to prevent path traversal in project settings."""
|
|
|
|
# Create a mock chainlit directory structure
|
|
app_dir = tmp_path / "app"
|
|
app_dir.mkdir()
|
|
(tmp_path / "README.md").write_text("This is a secret README")
|
|
|
|
# This is required for the exploit to occur.
|
|
chainlit_dir = app_dir / "chainlit_stuff"
|
|
chainlit_dir.mkdir()
|
|
|
|
# Mock the config root
|
|
test_config.root = str(app_dir)
|
|
|
|
# Attempt to access the file using path traversal
|
|
response = test_client.get(
|
|
"/project/settings", params={"language": "stuff/../../README"}
|
|
)
|
|
|
|
# Should not be able to read the file
|
|
assert "This is a secret README" not in response.text
|
|
|
|
assert response.status_code == 422
|
|
|
|
# The response should not contain the normally expected keys
|
|
data = response.json()
|
|
assert "ui" not in data
|
|
assert "features" not in data
|
|
assert "userEnv" not in data
|
|
assert "dataPersistence" not in data
|
|
assert "threadResumable" not in data
|
|
assert "markdown" not in data
|
|
assert "chatProfiles" not in data
|
|
assert "starters" not in data
|
|
assert "debugUrl" not in data
|
|
|
|
|
|
def test_get_avatar_default(test_client: TestClient, monkeypatch: pytest.MonkeyPatch):
|
|
"""Test with default avatar."""
|
|
response = test_client.get("/avatars/default")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("image/")
|
|
|
|
|
|
def test_get_avatar_custom(test_client: TestClient, monkeypatch: pytest.MonkeyPatch):
|
|
"""Test with custom avatar."""
|
|
custom_avatar_path = os.path.join(
|
|
APP_ROOT, "public", "avatars", "custom_avatar.png"
|
|
)
|
|
os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True)
|
|
with open(custom_avatar_path, "wb") as f:
|
|
f.write(b"fake image data")
|
|
|
|
response = test_client.get("/avatars/custom_avatar")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("image/")
|
|
assert response.content == b"fake image data"
|
|
|
|
# Clean up
|
|
os.remove(custom_avatar_path)
|
|
|
|
|
|
def test_get_avatar_with_spaces(
|
|
test_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Test with custom avatar."""
|
|
custom_avatar_path = os.path.join(APP_ROOT, "public", "avatars", "my_assistant.png")
|
|
os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True)
|
|
with open(custom_avatar_path, "wb") as f:
|
|
f.write(b"fake image data")
|
|
|
|
response = test_client.get("/avatars/My Assistant")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("image/")
|
|
assert response.content == b"fake image data"
|
|
|
|
# Clean up
|
|
os.remove(custom_avatar_path)
|
|
|
|
|
|
def test_get_avatar_non_existent_favicon(
|
|
test_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Test with non-existent avatar (should return favicon)."""
|
|
favicon_response = test_client.get("/favicon")
|
|
assert favicon_response.status_code == 200
|
|
|
|
response = test_client.get("/avatars/non_existent")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("image/")
|
|
assert response.content == favicon_response.content
|
|
|
|
|
|
def test_avatar_path_traversal(
|
|
test_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
):
|
|
"""Test to prevent potential path traversal in avatar route on Windows."""
|
|
|
|
# Create a Mock object for the glob function
|
|
mock_glob = Mock(return_value=[])
|
|
monkeypatch.setattr("chainlit.server.glob.glob", mock_glob)
|
|
|
|
mock_open_inst = mock_open(read_data=b'{"should_not": "Be readable."}')
|
|
monkeypatch.setattr("builtins.open", mock_open_inst)
|
|
|
|
# Attempt to access a file using path traversal
|
|
response = test_client.get("/avatars/..%5C..%5Capp")
|
|
|
|
# No glob should ever be called
|
|
assert not mock_glob.called
|
|
|
|
# Should return an error status
|
|
assert response.status_code == 400
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session_get_by_id_patched(mock_session: Mock, monkeypatch: pytest.MonkeyPatch):
|
|
test_session_id = "test_session_id"
|
|
|
|
# Mock the WebsocketSession.get_by_id method to return the mock session
|
|
monkeypatch.setattr(
|
|
"chainlit.session.WebsocketSession.get_by_id",
|
|
lambda session_id: mock_session if session_id == test_session_id else None,
|
|
)
|
|
|
|
return mock_session
|
|
|
|
|
|
def test_get_file_success(
|
|
test_client: TestClient,
|
|
mock_session_get_by_id_patched: Mock,
|
|
tmp_path: pathlib.Path,
|
|
mock_get_current_user: Mock,
|
|
):
|
|
"""
|
|
Test successful retrieval of a file from a session.
|
|
"""
|
|
# Set current_user to match session.user
|
|
mock_get_current_user.return_value = mock_session_get_by_id_patched.user
|
|
|
|
# Create test data
|
|
test_content = b"Test file content"
|
|
test_file_id = "test_file_id"
|
|
|
|
# Create a temporary file with the test content
|
|
test_file = tmp_path / "test_file"
|
|
test_file.write_bytes(test_content)
|
|
|
|
mock_session_get_by_id_patched.files = {
|
|
test_file_id: {
|
|
"id": test_file_id,
|
|
"path": test_file,
|
|
"name": "test.txt",
|
|
"type": "text/plain",
|
|
"size": len(test_content),
|
|
}
|
|
}
|
|
|
|
# Make the GET request to retrieve the file
|
|
response = test_client.get(
|
|
f"/project/file/{test_file_id}?session_id={mock_session_get_by_id_patched.id}"
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 200
|
|
assert response.content == test_content
|
|
assert response.headers["content-type"].startswith("text/plain")
|
|
|
|
|
|
def test_get_file_not_existent_file(
|
|
test_client: TestClient,
|
|
mock_session_get_by_id_patched: Mock,
|
|
mock_get_current_user: Mock,
|
|
):
|
|
"""
|
|
Test retrieval of a non-existing file from a session.
|
|
"""
|
|
# Set current_user to match session.user
|
|
mock_get_current_user.return_value = mock_session_get_by_id_patched.user
|
|
|
|
# Make the GET request to retrieve the file
|
|
response = test_client.get("/project/file/test_file_id?session_id=test_session_id")
|
|
|
|
# Verify the response
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_get_file_non_existing_session(
|
|
test_client: TestClient,
|
|
tmp_path: pathlib.Path,
|
|
mock_session_get_by_id_patched: Mock,
|
|
mock_session: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""
|
|
Test that an unauthenticated user cannot retrieve a file uploaded by an authenticated user.
|
|
"""
|
|
|
|
# Attempt to access the file without authentication by providing an invalid session_id
|
|
response = test_client.get(
|
|
"/project/file/nonexistent?session_id=unauthenticated_session_id"
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 401 # Unauthorized
|
|
|
|
|
|
def test_upload_file_success(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_session_get_by_id_patched: Mock,
|
|
):
|
|
"""Test successful file upload."""
|
|
|
|
# Prepare the files to upload
|
|
file_content = b"Sample file content"
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
# Mock the persist_file method to return a known value
|
|
expected_file_id = "mocked_file_id"
|
|
mock_session_get_by_id_patched.persist_file = AsyncMock(
|
|
return_value={
|
|
"id": expected_file_id,
|
|
"name": "test_upload.txt",
|
|
"type": "text/plain",
|
|
"size": len(file_content),
|
|
}
|
|
)
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
params={"session_id": mock_session_get_by_id_patched.id},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 200
|
|
response_data = response.json()
|
|
assert "id" in response_data
|
|
assert response_data["id"] == expected_file_id
|
|
assert response_data["name"] == "test_upload.txt"
|
|
assert response_data["type"] == "text/plain"
|
|
assert response_data["size"] == len(file_content)
|
|
|
|
# Verify that persist_file was called with the correct arguments
|
|
mock_session_get_by_id_patched.persist_file.assert_called_once_with(
|
|
name="test_upload.txt", content=file_content, mime="text/plain"
|
|
)
|
|
|
|
|
|
def test_file_access_by_different_user(
|
|
test_client: TestClient,
|
|
mock_session_get_by_id_patched: Mock,
|
|
persisted_test_user: PersistedUser,
|
|
tmp_path: pathlib.Path,
|
|
mock_session_factory: Callable[..., Mock],
|
|
):
|
|
"""Test that a file uploaded by one user cannot be accessed by another user."""
|
|
|
|
# Prepare the files to upload
|
|
file_content = b"Sample file content"
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
# Mock the persist_file method to return a known value
|
|
expected_file_id = "mocked_file_id"
|
|
mock_session_get_by_id_patched.persist_file = AsyncMock(
|
|
return_value={
|
|
"id": expected_file_id,
|
|
"name": "test_upload.txt",
|
|
"type": "text/plain",
|
|
"size": len(file_content),
|
|
}
|
|
)
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
params={"session_id": mock_session_get_by_id_patched.id},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 200
|
|
|
|
response_data = response.json()
|
|
assert "id" in response_data
|
|
file_id = response_data["id"]
|
|
|
|
# Create a second session with a different user
|
|
second_session = mock_session_factory(
|
|
id="another_session_id",
|
|
user=PersistedUser(
|
|
id="another_user_id",
|
|
createdAt=datetime.datetime.now().isoformat(),
|
|
identifier="another_user_identifier",
|
|
),
|
|
)
|
|
|
|
# Attempt to access the uploaded file using the second user's session
|
|
response = test_client.get(
|
|
f"/project/file/{file_id}?session_id={second_session.id}"
|
|
)
|
|
|
|
# Verify that the access attempt fails
|
|
assert response.status_code == 401 # Unauthorized
|
|
|
|
|
|
def test_upload_file_missing_file(
|
|
test_client: TestClient,
|
|
mock_session: Mock,
|
|
):
|
|
"""Test file upload with missing file in the request."""
|
|
|
|
# Make the POST request without a file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
data={"session_id": mock_session.id},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 422 # Unprocessable Entity
|
|
assert "detail" in response.json()
|
|
|
|
|
|
def test_upload_file_invalid_session(
|
|
test_client: TestClient,
|
|
):
|
|
"""Test file upload with an invalid session."""
|
|
|
|
# Prepare the files to upload
|
|
file_content = b"Sample file content"
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
# Make the POST request with an invalid session_id
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
data={"session_id": "invalid_session_id"},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_upload_file_unauthorized(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_session_get_by_id_patched: Mock,
|
|
):
|
|
"""Test file upload without proper authorization."""
|
|
|
|
# Mock the upload_file_session to have no user
|
|
mock_session_get_by_id_patched.user = None
|
|
|
|
# Prepare the files to upload
|
|
file_content = b"Sample file content"
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
data={"session_id": mock_session_get_by_id_patched.id},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_upload_file_disabled(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_session_get_by_id_patched: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Test file upload being disabled by config."""
|
|
|
|
# Set accept in config
|
|
monkeypatch.setattr(
|
|
test_config.features,
|
|
"spontaneous_file_upload",
|
|
SpontaneousFileUploadFeature(enabled=False),
|
|
)
|
|
|
|
# Prepare the files to upload
|
|
file_content = b"Sample file content"
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
params={"session_id": mock_session_get_by_id_patched.id},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == 400
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("accept_pattern", "mime_type", "expected_status"),
|
|
[
|
|
({"image/*": [".png", ".gif", ".jpeg", ".jpg"]}, "image/jpeg", 400),
|
|
(["image/*"], "text/plain", 400),
|
|
(["image/*", "application/*"], "text/plain", 400),
|
|
(["image/png", "application/pdf"], "image/jpeg", 400),
|
|
(["text/*"], "text/plain", 200),
|
|
(["application/*"], "application/pdf", 200),
|
|
(["image/*"], "image/jpeg", 200),
|
|
(["image/*", "text/*"], "text/plain", 200),
|
|
(["*/*"], "text/plain", 200),
|
|
(["*/*"], "image/jpeg", 200),
|
|
(["*/*"], "application/pdf", 200),
|
|
(["image/*", "application/*"], "application/pdf", 200),
|
|
(["image/*", "application/*"], "image/jpeg", 200),
|
|
(["image/png", "application/pdf"], "image/png", 200),
|
|
(["image/png", "application/pdf"], "application/pdf", 200),
|
|
({"image/*": []}, "image/jpeg", 200),
|
|
(
|
|
{"image/*": [".png", ".gif", ".jpeg", ".jpg"]},
|
|
"text/plain",
|
|
400,
|
|
), # mime type not allowed
|
|
(
|
|
{"*/*": [".txt", ".gif", ".jpeg", ".jpg"]},
|
|
"text/plain",
|
|
200,
|
|
), # extension allowed
|
|
(
|
|
{"*/*": [".gif", ".jpeg", ".jpg"]},
|
|
"text/plain",
|
|
400,
|
|
), # extension not allowed
|
|
],
|
|
)
|
|
def test_upload_file_mime_type_check(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_session_get_by_id_patched: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
accept_pattern: list[str],
|
|
mime_type: str,
|
|
expected_status: int,
|
|
):
|
|
"""Test check of mime_type."""
|
|
|
|
# Set accept in config
|
|
monkeypatch.setattr(
|
|
test_config.features,
|
|
"spontaneous_file_upload",
|
|
SpontaneousFileUploadFeature(enabled=True, accept=accept_pattern),
|
|
)
|
|
|
|
# Prepare the files to upload
|
|
file_content = b"Sample file content"
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, mime_type),
|
|
}
|
|
|
|
# Mock the persist_file method to return a known value
|
|
expected_file_id = "mocked_file_id"
|
|
mock_session_get_by_id_patched.persist_file = AsyncMock(
|
|
return_value={
|
|
"id": expected_file_id,
|
|
"name": "test_upload.txt",
|
|
"type": "text/plain",
|
|
"size": len(file_content),
|
|
}
|
|
)
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
params={"session_id": mock_session_get_by_id_patched.id},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == expected_status
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("file_content", "content_multiplier", "max_size_mb", "expected_status"),
|
|
[
|
|
(b"1", 1, 1, 200),
|
|
(b"11", 1024 * 1024, 1, 400),
|
|
],
|
|
)
|
|
def test_upload_file_size_check(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_session_get_by_id_patched: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
file_content: bytes,
|
|
content_multiplier: int,
|
|
max_size_mb: int,
|
|
expected_status: int,
|
|
):
|
|
"""Test check of max_size_mb."""
|
|
|
|
file_content = file_content * content_multiplier
|
|
|
|
# Set accept in config
|
|
monkeypatch.setattr(
|
|
test_config.features,
|
|
"spontaneous_file_upload",
|
|
SpontaneousFileUploadFeature(max_size_mb=max_size_mb, enabled=True),
|
|
)
|
|
|
|
# Prepare the files to upload
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
# Mock the persist_file method to return a known value
|
|
expected_file_id = "mocked_file_id"
|
|
mock_session_get_by_id_patched.persist_file = AsyncMock(
|
|
return_value={
|
|
"id": expected_file_id,
|
|
"name": "test_upload.txt",
|
|
"type": "text/plain",
|
|
"size": len(file_content),
|
|
}
|
|
)
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
params={"session_id": mock_session_get_by_id_patched.id},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == expected_status
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"file_content",
|
|
"content_multiplier",
|
|
"max_size_mb",
|
|
"parent_id",
|
|
"expected_status",
|
|
"accept",
|
|
),
|
|
[
|
|
(b"1", 1, 1, "mocked_parent_id", 200, ["text/plain"]),
|
|
(b"11", 1024 * 1024, 1, "mocked_parent_id", 400, ["text/plain"]),
|
|
(b"11", 1, 1, "invalid_parent_id", 404, ["text/plain"]),
|
|
(b"11", 1, 1, "mocked_parent_id", 400, ["image/gif"]),
|
|
],
|
|
)
|
|
def test_ask_file_with_spontaneous_upload_disabled(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_session_get_by_id_patched: Mock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
file_content: bytes,
|
|
content_multiplier: int,
|
|
max_size_mb: int,
|
|
parent_id: str,
|
|
expected_status: int,
|
|
accept: list[str],
|
|
):
|
|
"""Test file upload being disabled by config."""
|
|
|
|
# Set accept in config
|
|
monkeypatch.setattr(
|
|
test_config.features,
|
|
"spontaneous_file_upload",
|
|
SpontaneousFileUploadFeature(enabled=False),
|
|
)
|
|
|
|
# Prepare the files to upload
|
|
file_content = file_content * content_multiplier
|
|
files = {
|
|
"file": ("test_upload.txt", file_content, "text/plain"),
|
|
}
|
|
|
|
expected_file_id = "mocked_file_id"
|
|
mock_session_get_by_id_patched.persist_file = AsyncMock(
|
|
return_value={
|
|
"id": expected_file_id,
|
|
"name": "test_upload.txt",
|
|
"type": "text/plain",
|
|
"size": len(file_content),
|
|
}
|
|
)
|
|
|
|
mock_session_get_by_id_patched.files_spec = {
|
|
"mocked_parent_id": AskFileSpec(
|
|
step_id="mocked_file_spec",
|
|
timeout=1,
|
|
type="file",
|
|
accept=accept,
|
|
max_files=1,
|
|
max_size_mb=max_size_mb,
|
|
)
|
|
}
|
|
|
|
# Make the POST request to upload the file
|
|
response = test_client.post(
|
|
"/project/file",
|
|
files=files,
|
|
params={
|
|
"session_id": mock_session_get_by_id_patched.id,
|
|
"ask_parent_id": parent_id,
|
|
},
|
|
)
|
|
|
|
# Verify the response
|
|
assert response.status_code == expected_status
|
|
|
|
|
|
def test_project_translations_file_path_traversal(
|
|
test_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Test to prevent file path traversal in project translations."""
|
|
|
|
mock_open_inst = mock_open(read_data='{"should_not": "Be readable."}')
|
|
monkeypatch.setattr("builtins.open", mock_open_inst)
|
|
|
|
# Attempt to access the file using path traversal
|
|
response = test_client.get(
|
|
"/project/translations", params={"language": "/app/unreadable"}
|
|
)
|
|
|
|
# File should never be opened
|
|
assert not mock_open_inst.called
|
|
|
|
# Should give error status
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_project_settings_with_chat_profile_config_overrides(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Test that /project/settings endpoint returns merged configuration when chat_profile is specified."""
|
|
from chainlit.config import (
|
|
ChainlitConfigOverrides,
|
|
FeaturesSettings,
|
|
McpFeature,
|
|
UISettings,
|
|
)
|
|
from chainlit.types import ChatProfile
|
|
|
|
# Mock chat profiles with different config overrides
|
|
mock_profiles = [
|
|
ChatProfile(
|
|
name="basic",
|
|
markdown_description="Basic profile without overrides",
|
|
default=True,
|
|
),
|
|
ChatProfile(
|
|
name="mcp-enabled",
|
|
markdown_description="Profile with MCP enabled",
|
|
config_overrides=ChainlitConfigOverrides(
|
|
features=FeaturesSettings(mcp=McpFeature(enabled=True)),
|
|
ui=UISettings(name="MCP Assistant", default_theme="dark"),
|
|
),
|
|
),
|
|
ChatProfile(
|
|
name="light-theme",
|
|
markdown_description="Profile with light theme",
|
|
config_overrides=ChainlitConfigOverrides(
|
|
ui=UISettings(
|
|
name="Light Theme App",
|
|
default_theme="light",
|
|
description="Light theme app",
|
|
)
|
|
),
|
|
),
|
|
]
|
|
|
|
# Mock the chat profiles callback
|
|
async def mock_get_chat_profiles(user, language):
|
|
# Use asyncio.sleep to make this truly async
|
|
import asyncio
|
|
|
|
await asyncio.sleep(0)
|
|
return mock_profiles
|
|
|
|
test_config.code.set_chat_profiles = mock_get_chat_profiles
|
|
|
|
# Test 1: Default profile (no overrides)
|
|
response = test_client.get("/project/settings", params={"chat_profile": "basic"})
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Should return base configuration without overrides
|
|
assert config_data["ui"]["name"] == test_config.ui.name # Original name
|
|
assert (
|
|
config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled
|
|
) # Original MCP setting
|
|
|
|
# Test 2: MCP-enabled profile
|
|
response = test_client.get(
|
|
"/project/settings", params={"chat_profile": "mcp-enabled"}
|
|
)
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Should return merged configuration with MCP enabled and custom UI
|
|
assert config_data["features"]["mcp"]["enabled"] is True # Overridden
|
|
assert config_data["ui"]["name"] == "MCP Assistant" # Overridden
|
|
assert config_data["ui"]["default_theme"] == "dark" # Overridden
|
|
|
|
# Test 3: Light theme profile
|
|
response = test_client.get(
|
|
"/project/settings", params={"chat_profile": "light-theme"}
|
|
)
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Should return merged configuration with light theme
|
|
assert config_data["ui"]["default_theme"] == "light" # Overridden
|
|
assert config_data["ui"]["description"] == "Light theme app" # Overridden
|
|
assert (
|
|
config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled
|
|
) # Not overridden
|
|
|
|
# Test 4: Non-existent profile (should return base config)
|
|
response = test_client.get(
|
|
"/project/settings", params={"chat_profile": "non-existent"}
|
|
)
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Should return base configuration
|
|
assert config_data["ui"]["name"] == test_config.ui.name
|
|
assert config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled
|
|
|
|
# Test 5: No profile specified (should return base config)
|
|
response = test_client.get("/project/settings")
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Should return base configuration
|
|
assert config_data["ui"]["name"] == test_config.ui.name
|
|
assert config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled
|
|
|
|
|
|
def test_project_settings_config_overrides_serialization(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Test that config_overrides field is not included in serialized chat profiles."""
|
|
from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature
|
|
from chainlit.types import ChatProfile
|
|
|
|
# Mock chat profile with config overrides
|
|
mock_profile = ChatProfile(
|
|
name="test-profile",
|
|
markdown_description="Test profile",
|
|
config_overrides=ChainlitConfigOverrides(
|
|
features=FeaturesSettings(mcp=McpFeature(enabled=True))
|
|
),
|
|
)
|
|
|
|
async def mock_get_chat_profiles(user, language):
|
|
# Use asyncio.sleep to make this truly async
|
|
import asyncio
|
|
|
|
await asyncio.sleep(0)
|
|
return [mock_profile]
|
|
|
|
test_config.code.set_chat_profiles = mock_get_chat_profiles
|
|
|
|
# Get the project settings
|
|
response = test_client.get(
|
|
"/project/settings", params={"chat_profile": "test-profile"}
|
|
)
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Check that chatProfiles are included in the response
|
|
assert "chatProfiles" in config_data
|
|
assert len(config_data["chatProfiles"]) == 1
|
|
|
|
# Check that config_overrides is NOT included in the serialized profile
|
|
profile_data = config_data["chatProfiles"][0]
|
|
assert "config_overrides" not in profile_data
|
|
assert profile_data["name"] == "test-profile"
|
|
assert profile_data["markdown_description"] == "Test profile"
|
|
|
|
|
|
def test_project_settings_config_overrides_language(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Test that localized chat profiles use the right config overrides."""
|
|
from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature
|
|
from chainlit.types import ChatProfile
|
|
|
|
# Mock chat profile with config overrides
|
|
mock_profile_fr = ChatProfile(
|
|
name="test-profile-fr",
|
|
markdown_description="Test profil",
|
|
config_overrides=ChainlitConfigOverrides(
|
|
features=FeaturesSettings(mcp=McpFeature(enabled=True))
|
|
),
|
|
)
|
|
|
|
mock_profile_en = ChatProfile(
|
|
name="test-profile",
|
|
markdown_description="Test profile",
|
|
config_overrides=ChainlitConfigOverrides(
|
|
features=FeaturesSettings(mcp=McpFeature(enabled=False))
|
|
),
|
|
)
|
|
|
|
async def mock_get_chat_profiles(user, language):
|
|
# Use asyncio.sleep to make this truly async
|
|
import asyncio
|
|
|
|
await asyncio.sleep(0)
|
|
if language == "fr-CA":
|
|
return [mock_profile_fr]
|
|
|
|
return [mock_profile_en]
|
|
|
|
test_config.code.set_chat_profiles = mock_get_chat_profiles
|
|
|
|
# Get the project settings in French
|
|
response = test_client.get(
|
|
"/project/settings",
|
|
params={"language": "fr-CA", "chat_profile": "test-profile-fr"},
|
|
)
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Check that chatProfiles are included in the response
|
|
assert "chatProfiles" in config_data
|
|
assert len(config_data["chatProfiles"]) == 1
|
|
|
|
# Check that config_overrides is NOT included in the serialized profile
|
|
assert config_data["features"]["mcp"]["enabled"] is True # Overridden
|
|
|
|
# Check that the profile_data matches the selected profile.
|
|
profile_data = config_data["chatProfiles"][0]
|
|
assert "config_overrides" not in profile_data
|
|
assert profile_data["name"] == "test-profile-fr"
|
|
assert profile_data["markdown_description"] == "Test profil"
|
|
|
|
# Get the project settings in English
|
|
response = test_client.get(
|
|
"/project/settings",
|
|
params={"language": "en-US", "chat_profile": "test-profile"},
|
|
)
|
|
assert response.status_code == 200
|
|
config_data = response.json()
|
|
|
|
# Check that chatProfiles are included in the response
|
|
assert "chatProfiles" in config_data
|
|
assert len(config_data["chatProfiles"]) == 1
|
|
|
|
# Check that config_overrides is NOT included in the serialized profile
|
|
assert config_data["features"]["mcp"]["enabled"] is False # Overridden
|
|
|
|
# Check that the profile_data matches the selected profile.
|
|
profile_data = config_data["chatProfiles"][0]
|
|
assert "config_overrides" not in profile_data
|
|
assert profile_data["name"] == "test-profile"
|
|
assert profile_data["markdown_description"] == "Test profile"
|
|
|
|
|
|
def test_project_settings_thread_sharing_flag(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
# Start with both disabled
|
|
test_config.features.allow_thread_sharing = False
|
|
test_config.code.on_shared_thread_view = None
|
|
resp = test_client.get("/project/settings")
|
|
assert resp.status_code == 200
|
|
assert resp.json().get("threadSharing") is False
|
|
|
|
# Enable flag only -> still False (callback missing)
|
|
test_config.features.allow_thread_sharing = True
|
|
test_config.code.on_shared_thread_view = None
|
|
resp = test_client.get("/project/settings")
|
|
assert resp.status_code == 200
|
|
assert resp.json().get("threadSharing") is False
|
|
|
|
# Enable callback only -> still False (flag disabled)
|
|
test_config.features.allow_thread_sharing = False
|
|
|
|
def dummy_cb(*args, **kwargs):
|
|
return True
|
|
|
|
test_config.code.on_shared_thread_view = dummy_cb
|
|
resp = test_client.get("/project/settings")
|
|
assert resp.status_code == 200
|
|
assert resp.json().get("threadSharing") is False
|
|
|
|
# Enable both -> True
|
|
test_config.features.allow_thread_sharing = True
|
|
test_config.code.on_shared_thread_view = dummy_cb
|
|
resp = test_client.get("/project/settings")
|
|
assert resp.status_code == 200
|
|
assert resp.json().get("threadSharing") is True
|
|
|
|
|
|
def test_project_settings_forwards_chat_profile_to_starter_categories(
|
|
test_client: TestClient,
|
|
test_config: ChainlitConfig,
|
|
mock_get_current_user: Mock,
|
|
):
|
|
"""Test that /project/settings forwards chat_profile to set_starter_categories."""
|
|
from chainlit.types import Starter, StarterCategory
|
|
from chainlit.utils import wrap_user_function
|
|
|
|
async def _get_starter_categories(user, language, chat_profile):
|
|
if chat_profile == "creative":
|
|
return [
|
|
StarterCategory(
|
|
label="Writing",
|
|
starters=[Starter(label="Write a poem", message="poem")],
|
|
),
|
|
]
|
|
return [
|
|
StarterCategory(
|
|
label="General",
|
|
starters=[Starter(label="Hello", message="hello")],
|
|
),
|
|
]
|
|
|
|
test_config.code.set_starter_categories = wrap_user_function(
|
|
_get_starter_categories
|
|
)
|
|
|
|
response = test_client.get("/project/settings", params={"chat_profile": "creative"})
|
|
assert response.status_code == 200
|
|
categories = response.json()["starterCategories"]
|
|
assert len(categories) == 1
|
|
assert categories[0]["label"] == "Writing"
|
|
assert categories[0]["starters"][0]["label"] == "Write a poem"
|
|
|
|
response = test_client.get("/project/settings")
|
|
assert response.status_code == 200
|
|
categories = response.json()["starterCategories"]
|
|
assert len(categories) == 1
|
|
assert categories[0]["label"] == "General"
|
|
assert categories[0]["starters"][0]["label"] == "Hello"
|
|
|
|
|
|
def test_share_thread_endpoint_sets_flags(
|
|
test_client: TestClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
# Override current user to match thread author
|
|
from chainlit.server import app as _app, get_current_user as _get_current_user
|
|
|
|
author = PersistedUser(
|
|
id="u1",
|
|
createdAt=datetime.datetime.now().isoformat(),
|
|
identifier="author",
|
|
)
|
|
|
|
_app.dependency_overrides[_get_current_user] = lambda: author
|
|
|
|
# Mock data layer
|
|
from unittest.mock import AsyncMock
|
|
|
|
dl = AsyncMock()
|
|
dl.get_thread.return_value = {
|
|
"id": "t1",
|
|
"name": "Thread 1",
|
|
"userIdentifier": "author",
|
|
"metadata": {"other": True},
|
|
}
|
|
dl.get_thread_author.return_value = "author"
|
|
dl.build_debug_url.return_value = ""
|
|
|
|
# Ensure data layer is initialized for both server routes and ACL checks
|
|
import chainlit.data as data_mod
|
|
|
|
data_mod._data_layer = dl
|
|
data_mod._data_layer_initialized = True
|
|
|
|
# Share
|
|
r = test_client.put(
|
|
"/project/thread/share", json={"threadId": "t1", "isShared": True}
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
# Validate metadata passed to update_thread includes is_shared and shared_at
|
|
assert dl.update_thread.await_count >= 1
|
|
_, kwargs = dl.update_thread.await_args
|
|
assert kwargs.get("thread_id") == "t1"
|
|
meta = kwargs.get("metadata") or {}
|
|
assert meta.get("is_shared") is True
|
|
assert isinstance(meta.get("shared_at"), str)
|
|
|
|
# Unshare
|
|
r = test_client.put(
|
|
"/project/thread/share", json={"threadId": "t1", "isShared": False}
|
|
)
|
|
assert r.status_code == 200
|
|
_, kwargs = dl.update_thread.await_args
|
|
meta = kwargs.get("metadata") or {}
|
|
assert meta.get("is_shared") is False
|
|
assert "shared_at" not in meta
|
|
|
|
# Cleanup override and data layer
|
|
del _app.dependency_overrides[_get_current_user]
|
|
data_mod._data_layer = None
|
|
data_mod._data_layer_initialized = False
|
|
|
|
|
|
def test_health_check(test_client: TestClient):
|
|
response = test_client.get("/health")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|