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
518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""
|
|
Property-based fuzz tests for chat components using Hypothesis.
|
|
|
|
These tests verify that chat functions don't crash on arbitrary input
|
|
and handle edge cases gracefully.
|
|
"""
|
|
|
|
import pytest
|
|
from hypothesis import given, settings, strategies as st, assume, HealthCheck
|
|
from unittest.mock import MagicMock, patch
|
|
from contextlib import contextmanager
|
|
|
|
|
|
# =============================================================================
|
|
# Custom Strategies for Chat Testing
|
|
# =============================================================================
|
|
|
|
|
|
def message_roles():
|
|
"""Generate valid message roles."""
|
|
return st.sampled_from(["user", "assistant"])
|
|
|
|
|
|
def message_types():
|
|
"""Generate valid message types.
|
|
|
|
The schema removed "step" from ChatMessageType (steps live in
|
|
chat_progress_steps with their own enum-less schema); the chat
|
|
context layer filters out any "step" rows before they reach here.
|
|
"""
|
|
return st.sampled_from(["query", "followup", "response"])
|
|
|
|
|
|
def safe_text(max_size=200):
|
|
"""Generate text without problematic Unicode."""
|
|
return st.text(
|
|
alphabet=st.characters(
|
|
blacklist_categories=("Cs",), # Exclude surrogates
|
|
blacklist_characters=["\x00"], # Exclude null bytes
|
|
),
|
|
max_size=max_size,
|
|
)
|
|
|
|
|
|
def message_dict():
|
|
"""Generate a message dictionary."""
|
|
return st.fixed_dictionaries(
|
|
{
|
|
"id": st.text(
|
|
min_size=5, max_size=20, alphabet="abcdef0123456789-"
|
|
),
|
|
"role": message_roles(),
|
|
"content": safe_text(max_size=100),
|
|
"message_type": message_types(),
|
|
"research_id": st.one_of(
|
|
st.none(),
|
|
st.text(min_size=5, max_size=20, alphabet="abcdef0123456789-"),
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def accumulated_context_dict():
|
|
"""Generate an accumulated context dictionary."""
|
|
return st.fixed_dictionaries(
|
|
{
|
|
"key_entities": st.lists(safe_text(max_size=50), max_size=20),
|
|
"topics": st.lists(safe_text(max_size=30), max_size=10),
|
|
"summary": safe_text(max_size=200),
|
|
}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# ChatContextManager Fuzz Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestChatContextManagerFuzz:
|
|
"""Fuzz tests for ChatContextManager."""
|
|
|
|
@given(
|
|
messages=st.lists(message_dict(), max_size=10),
|
|
accumulated=st.one_of(st.none(), accumulated_context_dict()),
|
|
)
|
|
@settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_build_context_handles_arbitrary_messages(
|
|
self, messages, accumulated
|
|
):
|
|
"""Test that build_research_context never crashes on arbitrary input."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
try:
|
|
manager = ChatContextManager(
|
|
session_id="test-session",
|
|
messages=messages,
|
|
accumulated_context=accumulated,
|
|
)
|
|
context = manager.build_research_context()
|
|
|
|
# Verify result structure
|
|
assert isinstance(context, dict)
|
|
assert "session_id" in context
|
|
assert "is_multi_turn" in context
|
|
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"build_research_context crashed: {type(e).__name__}: {e}"
|
|
)
|
|
|
|
@given(accumulated=accumulated_context_dict())
|
|
@settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_accumulated_context_handles_arbitrary_data(self, accumulated):
|
|
"""Test that accumulated context with arbitrary data is handled."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
try:
|
|
manager = ChatContextManager(
|
|
session_id="test-session",
|
|
messages=[],
|
|
accumulated_context=accumulated,
|
|
)
|
|
|
|
# Test key entity extraction
|
|
entities = manager._get_key_entities()
|
|
assert isinstance(entities, list)
|
|
assert len(entities) <= 20 # Should be limited
|
|
|
|
# Test topics extraction
|
|
topics = manager._get_topics()
|
|
assert isinstance(topics, list)
|
|
assert len(topics) <= 10 # Should be limited
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Context handling crashed: {type(e).__name__}: {e}")
|
|
|
|
@given(content=safe_text(max_size=500))
|
|
@settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_create_summary_handles_arbitrary_content(self, content):
|
|
"""Test that summary creation handles arbitrary content."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
try:
|
|
manager = ChatContextManager("test-session", [], {})
|
|
summary = manager._create_summary(content)
|
|
|
|
# Should return a string
|
|
assert isinstance(summary, str)
|
|
# Should be limited in length
|
|
assert len(summary) <= 400 # 300 + "..."
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Summary creation crashed: {type(e).__name__}: {e}")
|
|
|
|
@given(content=safe_text(max_size=200))
|
|
@settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_extract_context_updates_handles_arbitrary_input(self, content):
|
|
"""Test that context update extraction handles arbitrary input."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
try:
|
|
manager = ChatContextManager("test-session", [], {})
|
|
updates = manager.extract_context_updates(content)
|
|
|
|
# Verify structure
|
|
assert isinstance(updates, dict)
|
|
assert "new_entities" in updates
|
|
assert "new_topics" in updates
|
|
assert "summary_addition" in updates
|
|
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"Context update extraction crashed: {type(e).__name__}: {e}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# ChatService Fuzz Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestChatServiceFuzz:
|
|
"""Fuzz tests for ChatService."""
|
|
|
|
@given(query=st.one_of(st.none(), safe_text(max_size=200)))
|
|
@settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_title_generation_handles_any_string(self, query):
|
|
"""Test that title generation handles any input string."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
try:
|
|
service = ChatService(username="test_user")
|
|
title = service._generate_title(query)
|
|
|
|
# Should return a string
|
|
assert isinstance(title, str)
|
|
# Should have reasonable length
|
|
assert len(title) <= 150 # 100 + "..."
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Title generation crashed: {type(e).__name__}: {e}")
|
|
|
|
@given(
|
|
content=safe_text(max_size=100),
|
|
role=message_roles(),
|
|
message_type=message_types(),
|
|
)
|
|
@settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_add_message_handles_unicode(self, content, role, message_type):
|
|
"""Test that add_message handles various Unicode content."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
# Skip empty content for valid messages
|
|
assume(len(content.strip()) > 0)
|
|
|
|
@contextmanager
|
|
def mock_get_user_db_session(username, password=None):
|
|
mock_session = MagicMock()
|
|
# Mock the session query to return a valid session
|
|
mock_chat_session = MagicMock()
|
|
mock_chat_session.message_count = 0
|
|
mock_session.query.return_value.filter_by.return_value.with_for_update.return_value.first.return_value = mock_chat_session
|
|
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_chat_session
|
|
mock_session.add = MagicMock()
|
|
mock_session.commit = MagicMock()
|
|
yield mock_session
|
|
|
|
with patch(
|
|
"src.local_deep_research.chat.service.get_user_db_session",
|
|
mock_get_user_db_session,
|
|
):
|
|
try:
|
|
service = ChatService(username="test_user")
|
|
message_id = service.add_message(
|
|
session_id="test-session",
|
|
role=role,
|
|
content=content,
|
|
message_type=message_type,
|
|
)
|
|
|
|
# Should return a UUID string
|
|
assert isinstance(message_id, str)
|
|
assert len(message_id) > 0
|
|
|
|
except ValueError:
|
|
# Session not found is expected in some cases
|
|
pass
|
|
except Exception as e:
|
|
pytest.fail(f"add_message crashed: {type(e).__name__}: {e}")
|
|
|
|
|
|
# =============================================================================
|
|
# Chat API Fuzz Tests (Parametrized for performance)
|
|
# =============================================================================
|
|
|
|
|
|
# Test data for API fuzzing - these are checked without Hypothesis
|
|
# to avoid the fixture overhead with each hypothesis iteration
|
|
SPECIAL_CONTENT_CASES = [
|
|
"Normal message",
|
|
"", # Empty
|
|
" " * 100, # Whitespace
|
|
"Unicode: Привет 你好 🎉",
|
|
"<script>alert('XSS')</script>",
|
|
"'\"<>&", # HTML special chars
|
|
"Line1\nLine2\rLine3",
|
|
"A" * 9999, # Near limit
|
|
"\x00\x01\x02", # Control chars
|
|
]
|
|
|
|
UNICODE_TITLE_CASES = [
|
|
"Normal Title",
|
|
"",
|
|
"Привет мир",
|
|
"你好世界",
|
|
"🎉🔥💯",
|
|
"<script>",
|
|
"A" * 499, # Near limit
|
|
]
|
|
|
|
PAGINATION_CASES = [
|
|
(-1, 0),
|
|
(0, 0),
|
|
(1, 0),
|
|
(100, 0),
|
|
(101, 0),
|
|
(20, -1),
|
|
(20, 0),
|
|
(20, 1000),
|
|
]
|
|
|
|
STATUS_CASES = [
|
|
"active",
|
|
"archived",
|
|
"all",
|
|
"invalid",
|
|
"",
|
|
"' OR '1'='1",
|
|
"<script>",
|
|
]
|
|
|
|
|
|
class TestChatAPIFuzz:
|
|
"""Fuzz tests for Chat API endpoints using parametrized tests."""
|
|
|
|
@pytest.mark.parametrize("content", SPECIAL_CONTENT_CASES)
|
|
def test_send_message_handles_special_characters(
|
|
self, content, authenticated_client
|
|
):
|
|
"""Test that send message handles special characters in content."""
|
|
import json
|
|
|
|
# Create a session first
|
|
create_response = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "Test"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
# Session creation is the precondition, NOT the system under
|
|
# fuzz. Skipping on a non-200 here would silently mask any
|
|
# regression in POST /api/chat/sessions (CI would go green for
|
|
# every parametrized case). Assert so a broken precondition
|
|
# fails loudly.
|
|
assert create_response.status_code == 200, (
|
|
f"session creation precondition failed: "
|
|
f"{create_response.status_code} {create_response.data!r}"
|
|
)
|
|
|
|
session_id = json.loads(create_response.data)["session_id"]
|
|
|
|
# Send message with test content
|
|
response = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/messages",
|
|
json={"content": content, "trigger_research": False},
|
|
content_type="application/json",
|
|
)
|
|
|
|
# Should not crash. 200 = accepted, 400 = validation error.
|
|
# 500 here would be an unhandled-exception regression on user input.
|
|
assert response.status_code in [200, 400]
|
|
|
|
@pytest.mark.parametrize("title", UNICODE_TITLE_CASES)
|
|
def test_session_title_update_handles_unicode(
|
|
self, title, authenticated_client
|
|
):
|
|
"""Test that session title update handles Unicode strings."""
|
|
import json
|
|
|
|
# Create a session
|
|
create_response = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "Test"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
# Precondition assert — see test_message_send_handles_unicode
|
|
# for the rationale (skip would mask session-create regression).
|
|
assert create_response.status_code == 200, (
|
|
f"session creation precondition failed: "
|
|
f"{create_response.status_code} {create_response.data!r}"
|
|
)
|
|
|
|
session_id = json.loads(create_response.data)["session_id"]
|
|
|
|
# Update with test title
|
|
response = authenticated_client.patch(
|
|
f"/api/chat/sessions/{session_id}",
|
|
json={"title": title},
|
|
content_type="application/json",
|
|
)
|
|
|
|
# Should not crash. 200 = accepted, 400 = validation error.
|
|
# 500 here would be an unhandled-exception regression on user input.
|
|
assert response.status_code in [200, 400]
|
|
|
|
@pytest.mark.parametrize("limit,offset", PAGINATION_CASES)
|
|
def test_pagination_handles_edge_cases(
|
|
self, limit, offset, authenticated_client
|
|
):
|
|
"""Test that pagination parameters handle edge cases."""
|
|
import json
|
|
|
|
response = authenticated_client.get(
|
|
f"/api/chat/sessions?limit={limit}&offset={offset}"
|
|
)
|
|
|
|
# Should not crash - may clamp values to valid range
|
|
assert response.status_code in [200, 400]
|
|
|
|
if response.status_code == 200:
|
|
data = json.loads(response.data)
|
|
assert "sessions" in data
|
|
|
|
@pytest.mark.parametrize("status", STATUS_CASES)
|
|
def test_list_sessions_handles_invalid_status(
|
|
self, status, authenticated_client
|
|
):
|
|
"""Test that list sessions handles invalid status values."""
|
|
import json
|
|
|
|
response = authenticated_client.get(
|
|
f"/api/chat/sessions?status={status}"
|
|
)
|
|
|
|
# Should not crash - invalid status should default to "active"
|
|
assert response.status_code == 200
|
|
|
|
data = json.loads(response.data)
|
|
assert data["success"] is True
|
|
|
|
|
|
# =============================================================================
|
|
# Prompt Context Fuzz Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestPromptContextFuzz:
|
|
"""Fuzz tests for findings extraction."""
|
|
|
|
@given(messages=st.lists(message_dict(), min_size=5, max_size=20))
|
|
@settings(max_examples=20, suppress_health_check=[HealthCheck.too_slow])
|
|
def test_findings_extraction_handles_many_messages(self, messages):
|
|
"""Test that findings extraction handles many messages."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
try:
|
|
manager = ChatContextManager("test-session", messages, {})
|
|
findings = manager._extract_findings_from_history()
|
|
|
|
# Should return a string
|
|
assert isinstance(findings, str)
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Findings extraction crashed: {type(e).__name__}: {e}")
|
|
|
|
|
|
# =============================================================================
|
|
# Edge Case Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for specific edge cases that could cause issues."""
|
|
|
|
def test_empty_messages_list(self):
|
|
"""Test handling of empty messages list."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
manager = ChatContextManager("test-session", [], {})
|
|
context = manager.build_research_context()
|
|
|
|
assert context["is_multi_turn"] is False
|
|
assert context["turn_count"] == 0
|
|
|
|
def test_none_messages_list(self):
|
|
"""Test handling of None messages."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
manager = ChatContextManager("test-session", None, {})
|
|
context = manager.build_research_context()
|
|
|
|
assert context["is_multi_turn"] is False
|
|
|
|
def test_none_accumulated_context(self):
|
|
"""Test handling of None accumulated context."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
manager = ChatContextManager("test-session", [], None)
|
|
entities = manager._get_key_entities()
|
|
topics = manager._get_topics()
|
|
|
|
assert entities == []
|
|
assert topics == []
|
|
|
|
def test_very_long_content_truncation(self):
|
|
"""Test that very long content is properly truncated."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
# Create message with very long content
|
|
long_content = "A" * 10000
|
|
messages = [
|
|
{
|
|
"id": "msg-1",
|
|
"role": "assistant",
|
|
"content": long_content,
|
|
"message_type": "response",
|
|
"research_id": "research-1",
|
|
}
|
|
]
|
|
|
|
manager = ChatContextManager("test-session", messages, {})
|
|
findings = manager.build_research_context()["accumulated_findings"]
|
|
|
|
# Should be significantly shorter than original
|
|
assert len(findings) < len(long_content)
|
|
|
|
def test_special_characters_in_entities(self):
|
|
"""Test handling of special characters in entities."""
|
|
from src.local_deep_research.chat.context import ChatContextManager
|
|
|
|
accumulated = {
|
|
"key_entities": [
|
|
"entity<script>",
|
|
"entity'with'quotes",
|
|
'entity"double',
|
|
"entity\nnewline",
|
|
],
|
|
"topics": ["topic<tag>", "topic;semicolon"],
|
|
"summary": "Summary with <html> tags",
|
|
}
|
|
|
|
manager = ChatContextManager("test-session", [], accumulated)
|
|
context = manager.build_research_context()
|
|
|
|
# Should not crash and should include entities
|
|
assert len(context["key_entities"]) > 0
|