7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
431 lines
14 KiB
Python
431 lines
14 KiB
Python
"""Fixtures for chat feature tests."""
|
|
|
|
import pytest
|
|
import uuid
|
|
from unittest.mock import MagicMock, patch
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_request_path_llm():
|
|
"""Keep the send-message request path from making real LLM calls.
|
|
|
|
build_research_context() condenses prior turns into a query-focused
|
|
summary via get_llm() before research is dispatched. Most chat tests
|
|
don't exercise that summary (they mock the research worker), so stub the
|
|
LLM to keep them fast and deterministic regardless of provider
|
|
availability. Tests that assert on the summary patch get_llm themselves,
|
|
which overrides this stub within their own context.
|
|
"""
|
|
fake_llm = MagicMock()
|
|
fake_llm.invoke.return_value = MagicMock(
|
|
content="stub conversation summary"
|
|
)
|
|
with patch(
|
|
"local_deep_research.config.llm_config.get_llm",
|
|
return_value=fake_llm,
|
|
):
|
|
yield
|
|
|
|
|
|
def generate_unique_test_username():
|
|
"""Generate a unique test username to avoid conflicts in parallel tests."""
|
|
return f"test_user_{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def setup_query_mock_with_session(db_session_mock, session_obj):
|
|
"""
|
|
Set up query mock to return session for the call patterns used by ChatService:
|
|
- filter_by().first() — get_session, archive, delete, etc.
|
|
- filter_by().with_for_update().first() — update_accumulated_context
|
|
- execute(update(...).returning(...)).scalar_one_or_none() — add_message
|
|
(atomic increment-and-return; mutates session_obj.message_count to
|
|
mirror the SQL UPDATE side effect so callers can assert on it).
|
|
|
|
Args:
|
|
db_session_mock: The mock database session
|
|
session_obj: The mock session object to return (or None for not found)
|
|
"""
|
|
query_mock = MagicMock()
|
|
filter_result = MagicMock()
|
|
|
|
# Set up both patterns to return the same session
|
|
filter_result.first.return_value = session_obj
|
|
filter_result.with_for_update.return_value.first.return_value = session_obj
|
|
|
|
query_mock.filter_by.return_value = filter_result
|
|
db_session_mock.query.return_value = query_mock
|
|
|
|
def _execute_side_effect(_stmt, *args, **kwargs):
|
|
result = MagicMock()
|
|
if session_obj is None:
|
|
result.scalar_one_or_none.return_value = None
|
|
else:
|
|
current = session_obj.message_count or 0
|
|
session_obj.message_count = current + 1
|
|
result.scalar_one_or_none.return_value = session_obj.message_count
|
|
return result
|
|
|
|
db_session_mock.execute.side_effect = _execute_side_effect
|
|
|
|
return query_mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_user_db_session():
|
|
"""Mock the database session context manager."""
|
|
session_mock = MagicMock()
|
|
|
|
@contextmanager
|
|
def _mock_session(username, password=None):
|
|
yield session_mock
|
|
|
|
with patch(
|
|
"src.local_deep_research.chat.service.get_user_db_session",
|
|
side_effect=_mock_session,
|
|
):
|
|
yield session_mock
|
|
|
|
|
|
@pytest.fixture
|
|
def chat_service(setup_database_for_all_tests):
|
|
"""Provide ChatService with test database."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
return ChatService(username="test_user")
|
|
|
|
|
|
@pytest.fixture
|
|
def csrf_authenticated_client(app_with_csrf, temp_data_dir):
|
|
"""Authenticated test client against the CSRF-enabled app.
|
|
|
|
Returns ``(client, csrf_token)``. The token is the value the client
|
|
must send as ``X-CSRFToken`` (or as a ``csrf_token`` form field)
|
|
for mutating requests to be accepted; tests that omit the header
|
|
verify that Flask-WTF rejects the request.
|
|
|
|
Mirrors the registration/login flow of the no-CSRF
|
|
``authenticated_client`` fixture in tests/conftest.py — auth pages
|
|
use ``WTForms``-managed CSRF tokens so the standard
|
|
register-then-login pattern still works against ``app_with_csrf``
|
|
when we read the token from the form's ``<input name="csrf_token">``
|
|
first.
|
|
"""
|
|
import re
|
|
import shutil
|
|
|
|
test_username = generate_unique_test_username()
|
|
test_password = "TestPass123"
|
|
|
|
encrypted_db_dir = temp_data_dir / "encrypted_databases"
|
|
if encrypted_db_dir.exists():
|
|
try:
|
|
shutil.rmtree(encrypted_db_dir)
|
|
except Exception:
|
|
pass
|
|
|
|
client = app_with_csrf.test_client()
|
|
csrf_input_re = re.compile(
|
|
rb'name="csrf_token"\s+(?:type="hidden"\s+)?value="([^"]+)"'
|
|
)
|
|
csrf_meta_re = re.compile(rb'name="csrf-token"\s+content="([^"]+)"')
|
|
|
|
def _extract_csrf(resp_data):
|
|
m = csrf_input_re.search(resp_data) or csrf_meta_re.search(resp_data)
|
|
return m.group(1).decode() if m else None
|
|
|
|
# 1. GET the register form to obtain its CSRF token, then POST
|
|
# register. Successful registration auto-logs the user in (route
|
|
# redirects to "/"), so a separate login step is not needed.
|
|
reg_form = client.get("/auth/register")
|
|
reg_token = _extract_csrf(reg_form.data)
|
|
if not reg_token:
|
|
raise RuntimeError("CSRF-fixture: no token in /auth/register form")
|
|
|
|
register_resp = client.post(
|
|
"/auth/register",
|
|
data={
|
|
"username": test_username,
|
|
"password": test_password,
|
|
"confirm_password": test_password,
|
|
"acknowledge": "true",
|
|
"csrf_token": reg_token,
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
if register_resp.status_code not in [200, 302]:
|
|
raise RuntimeError(
|
|
f"CSRF-fixture registration failed: {register_resp.status_code} "
|
|
f"{register_resp.data.decode()[:300]}"
|
|
)
|
|
|
|
# 2. GET an authenticated page to harvest the post-login CSRF token
|
|
# that mutating chat-API requests must echo back. The chat page
|
|
# embeds it in <meta name="csrf-token">.
|
|
page = client.get("/chat/")
|
|
api_token = _extract_csrf(page.data)
|
|
if not api_token:
|
|
raise RuntimeError(
|
|
f"CSRF-fixture: no csrf-token in /chat/ "
|
|
f"(status={page.status_code}, "
|
|
f"location={page.headers.get('Location')})"
|
|
)
|
|
|
|
return client, api_token
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_messages():
|
|
"""Sample message list for context manager tests."""
|
|
return [
|
|
{
|
|
"id": "msg-001",
|
|
"role": "user",
|
|
"content": "What is quantum computing?",
|
|
"message_type": "query",
|
|
"created_at": datetime(2024, 1, 15, 10, 0, 0),
|
|
},
|
|
{
|
|
"id": "msg-002",
|
|
"role": "assistant",
|
|
"content": "Quantum computing uses quantum mechanics principles like superposition and entanglement to process information. Unlike classical computers that use bits (0 or 1), quantum computers use qubits that can exist in multiple states simultaneously.",
|
|
"message_type": "response",
|
|
"research_id": "research-abc123",
|
|
"created_at": datetime(2024, 1, 15, 10, 1, 0),
|
|
},
|
|
{
|
|
"id": "msg-003",
|
|
"role": "user",
|
|
"content": "What are the practical applications?",
|
|
"message_type": "query",
|
|
"created_at": datetime(2024, 1, 15, 10, 2, 0),
|
|
},
|
|
{
|
|
"id": "msg-004",
|
|
"role": "assistant",
|
|
"content": "Practical applications include cryptography, drug discovery, financial modeling, and optimization problems.",
|
|
"message_type": "response",
|
|
"research_id": "research-def456",
|
|
"created_at": datetime(2024, 1, 15, 10, 3, 0),
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_accumulated_context():
|
|
"""Sample accumulated context."""
|
|
return {
|
|
"key_entities": [
|
|
"quantum computing",
|
|
"qubits",
|
|
"superposition",
|
|
"entanglement",
|
|
],
|
|
"topics": ["physics", "computing", "cryptography"],
|
|
"summary": "Discussion about quantum computing fundamentals and applications.",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_chat_session():
|
|
"""Create a mock ChatSession object."""
|
|
session = MagicMock()
|
|
session.id = "session-test-123"
|
|
session.title = "Test Chat Session"
|
|
session.status = "active"
|
|
session.message_count = 0
|
|
session.accumulated_context = {}
|
|
session.created_at = datetime(2024, 1, 15, 10, 0, 0)
|
|
session.updated_at = datetime(2024, 1, 15, 10, 0, 0).isoformat()
|
|
return session
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings_manager():
|
|
"""Mock the SettingsManager."""
|
|
manager_mock = MagicMock()
|
|
manager_mock.get_all_settings.return_value = {
|
|
"llm.provider": {"value": "ollama"},
|
|
"llm.model": {"value": "gemma:latest"},
|
|
"search.tool": {"value": "searxng"},
|
|
"search.iterations": {"value": 2},
|
|
"search.questions_per_iteration": {"value": 3},
|
|
}
|
|
return manager_mock
|
|
|
|
|
|
@pytest.fixture
|
|
def long_query_text():
|
|
"""A query text longer than 100 characters for title truncation tests."""
|
|
return (
|
|
"Can you explain the fundamental principles of quantum computing and how "
|
|
"it differs from classical computing, including practical applications "
|
|
"in cryptography, drug discovery, and financial modeling?"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def many_messages():
|
|
"""Generate more than 10 messages for limit testing."""
|
|
messages = []
|
|
for i in range(15):
|
|
messages.append(
|
|
{
|
|
"id": f"msg-{i:03d}",
|
|
"role": "user" if i % 2 == 0 else "assistant",
|
|
"content": f"Message content {i}",
|
|
"message_type": "query" if i % 2 == 0 else "response",
|
|
"research_id": f"research-{i:03d}" if i % 2 == 1 else None,
|
|
"created_at": datetime(2024, 1, 15, 10, i, 0),
|
|
}
|
|
)
|
|
return messages
|
|
|
|
|
|
# =============================================================================
|
|
# Security Test Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def second_user_client(app, temp_data_dir):
|
|
"""
|
|
Create a second authenticated test client for cross-user tests.
|
|
|
|
This creates a separate user to test user isolation and
|
|
cross-user access prevention.
|
|
"""
|
|
# Create unique test username
|
|
test_username = generate_unique_test_username()
|
|
test_password = "testpassword456"
|
|
|
|
# Create a test client
|
|
client = app.test_client()
|
|
|
|
# Register and login the second user
|
|
with client:
|
|
# Register new user
|
|
register_response = client.post(
|
|
"/auth/register",
|
|
data={
|
|
"username": test_username,
|
|
"password": test_password,
|
|
"confirm_password": test_password,
|
|
"acknowledge": "true",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
if register_response.status_code not in [200, 302]:
|
|
raise Exception(
|
|
f"Second user registration failed: {register_response.status_code}"
|
|
)
|
|
|
|
# Login user
|
|
login_response = client.post(
|
|
"/auth/login",
|
|
data={"username": test_username, "password": test_password},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
if login_response.status_code not in [200, 302]:
|
|
raise Exception(
|
|
f"Second user login failed: {login_response.status_code}"
|
|
)
|
|
|
|
return client
|
|
|
|
|
|
# =============================================================================
|
|
# XSS Test Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def xss_payloads():
|
|
"""Common XSS attack vectors for testing."""
|
|
return {
|
|
"script_tags": [
|
|
"<script>alert('XSS')</script>",
|
|
"<script>alert(document.cookie)</script>",
|
|
"<SCRIPT>alert('XSS')</SCRIPT>",
|
|
"<script src='http://evil.com/xss.js'></script>",
|
|
],
|
|
"event_handlers": [
|
|
"<img src=x onerror=alert('XSS')>",
|
|
"<svg onload=alert('XSS')>",
|
|
"<body onload=alert('XSS')>",
|
|
"<div onmouseover=alert('XSS')>hover</div>",
|
|
],
|
|
"javascript_urls": [
|
|
"<a href='javascript:alert(1)'>Click</a>",
|
|
"<iframe src='javascript:alert(1)'></iframe>",
|
|
],
|
|
"nested": [
|
|
"<<script>script>alert('XSS')<</script>/script>",
|
|
"<scr<script>ipt>alert('XSS')</scr</script>ipt>",
|
|
],
|
|
"encoded": [
|
|
"<script>alert('XSS')</script>",
|
|
"%3Cscript%3Ealert('XSS')%3C/script%3E",
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def malicious_session_ids():
|
|
"""Session ID values that could be used in attacks."""
|
|
return [
|
|
# Path traversal
|
|
"../../../etc/passwd",
|
|
"..%2F..%2Fetc%2Fpasswd",
|
|
"session-id/../../admin",
|
|
# Null bytes
|
|
"valid-id%00.txt",
|
|
"test\x00admin",
|
|
# SQL injection
|
|
"' OR '1'='1",
|
|
"'; DROP TABLE chat_sessions; --",
|
|
"1 UNION SELECT * FROM users--",
|
|
# XSS in IDs
|
|
"<script>alert(1)</script>",
|
|
"session-<img onerror=alert(1)>",
|
|
]
|
|
|
|
|
|
# =============================================================================
|
|
# Fuzz Test Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def random_unicode_strings():
|
|
"""Collection of Unicode strings for fuzz testing."""
|
|
return [
|
|
"", # Empty
|
|
" ", # Whitespace
|
|
"\t\n\r", # Control chars
|
|
"Hello, World!", # Normal ASCII
|
|
"Привет мир", # Cyrillic
|
|
"你好世界", # Chinese
|
|
"こんにちは", # Japanese
|
|
"مرحبا", # Arabic
|
|
"🎉🔥💯", # Emoji
|
|
"a" * 10000, # Very long
|
|
"\x00\x01\x02", # Null and control
|
|
"line1\nline2\rline3", # Newlines
|
|
"'\"<>&", # HTML special chars
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def boundary_values():
|
|
"""Boundary values for testing limits."""
|
|
return {
|
|
"message_lengths": [0, 1, 99, 100, 101, 9999, 10000, 10001],
|
|
"title_lengths": [0, 1, 499, 500, 501],
|
|
"pagination_limits": [-1, 0, 1, 20, 100, 101, 1000],
|
|
"pagination_offsets": [-1, 0, 1, 100, 10000],
|
|
}
|