chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API Tests for Local Deep Research."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Fixtures for library API tests.
|
||||
Provides test data and helpers for testing library/collection/RAG endpoints.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from local_deep_research.database.models.library import (
|
||||
DocumentStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_source_type_data():
|
||||
"""Sample source type data for test documents."""
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "test_upload",
|
||||
"display_name": "Test Upload",
|
||||
"description": "Test documents for API tests",
|
||||
"icon": "fas fa-file",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_collection_data():
|
||||
"""Sample collection data for tests."""
|
||||
return {
|
||||
"name": f"Test Collection {uuid.uuid4().hex[:8]}",
|
||||
"description": "A test collection for API tests",
|
||||
"type": "user_uploads",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_document_data():
|
||||
"""Sample document data for tests."""
|
||||
content = "This is sample text content for testing API endpoints."
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"document_hash": hashlib.sha256(content.encode()).hexdigest(),
|
||||
"file_size": len(content),
|
||||
"file_type": "text",
|
||||
"text_content": content,
|
||||
"title": "Test Document for API",
|
||||
"status": DocumentStatus.COMPLETED,
|
||||
}
|
||||
|
||||
|
||||
def create_test_collection(client, name=None, description=None):
|
||||
"""
|
||||
Helper to create a collection via API.
|
||||
|
||||
Args:
|
||||
client: Flask test client (authenticated)
|
||||
name: Collection name (optional, auto-generated if not provided)
|
||||
description: Collection description (optional)
|
||||
|
||||
Returns:
|
||||
dict: Created collection data or None if failed
|
||||
"""
|
||||
payload = {
|
||||
"name": name or f"Test Collection {uuid.uuid4().hex[:8]}",
|
||||
"description": description or "Test collection",
|
||||
"type": "user_uploads",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/library/api/collections",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.get_json()
|
||||
if data.get("success"):
|
||||
return data.get("collection")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def delete_test_collection(client, collection_id):
|
||||
"""
|
||||
Helper to delete a collection via API.
|
||||
|
||||
Args:
|
||||
client: Flask test client (authenticated)
|
||||
collection_id: ID of collection to delete
|
||||
|
||||
Returns:
|
||||
bool: True if deleted successfully
|
||||
"""
|
||||
response = client.delete(f"/library/api/collections/{collection_id}")
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_collection_helper(authenticated_client):
|
||||
"""Fixture that returns a helper function to create collections."""
|
||||
created_collections = []
|
||||
|
||||
def _create(name=None, description=None):
|
||||
collection = create_test_collection(
|
||||
authenticated_client, name, description
|
||||
)
|
||||
if collection:
|
||||
created_collections.append(collection["id"])
|
||||
return collection
|
||||
|
||||
yield _create
|
||||
|
||||
# Cleanup: delete all created collections
|
||||
for coll_id in created_collections:
|
||||
try:
|
||||
delete_test_collection(authenticated_client, coll_id)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Quick fix to ensure search engine settings are loaded for test user.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from local_deep_research.database.encrypted_db import db_manager
|
||||
from local_deep_research.settings.manager import (
|
||||
SettingsManager,
|
||||
)
|
||||
|
||||
|
||||
def fix_search_engines_for_user(username: str, password: str):
|
||||
"""Ensure search engine settings are loaded for a user."""
|
||||
|
||||
logger.info(f"Fixing search engine settings for user: {username}")
|
||||
|
||||
# Open user database
|
||||
engine = db_manager.open_user_database(username, password)
|
||||
if not engine:
|
||||
logger.error("Failed to open user database")
|
||||
return False
|
||||
|
||||
# Get database session
|
||||
Session = db_manager.Session
|
||||
if not Session:
|
||||
logger.error("Failed to get database session factory")
|
||||
return False
|
||||
|
||||
with Session() as db_session:
|
||||
try:
|
||||
# Create settings manager
|
||||
settings_manager = SettingsManager(db_session)
|
||||
|
||||
# Check if we need to load defaults
|
||||
from local_deep_research.database.models import Setting
|
||||
|
||||
search_engine_count = (
|
||||
db_session.query(Setting)
|
||||
.filter(Setting.key.like("search.engine.%.display_name"))
|
||||
.count()
|
||||
)
|
||||
|
||||
if search_engine_count == 0:
|
||||
logger.info(
|
||||
"No search engine settings found. Loading defaults..."
|
||||
)
|
||||
settings_manager.load_from_defaults_file(commit=True)
|
||||
logger.info("Default settings loaded successfully")
|
||||
else:
|
||||
logger.info(
|
||||
f"Found {search_engine_count} search engine settings"
|
||||
)
|
||||
|
||||
# Verify settings were loaded
|
||||
search_engines = (
|
||||
db_session.query(Setting)
|
||||
.filter(Setting.key.like("search.engine.%.display_name"))
|
||||
.all()
|
||||
)
|
||||
|
||||
logger.info(f"Search engines after fix: {len(search_engines)}")
|
||||
for engine in search_engines[:5]: # Show first 5
|
||||
logger.info(f" - {engine.key}: {engine.value}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to fix search engines")
|
||||
db_session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "true"
|
||||
|
||||
# Fix for test user
|
||||
fix_search_engines_for_user(
|
||||
"testuser", "testpassword123"
|
||||
) # pragma: allowlist secret
|
||||
|
||||
# Also fix for any other common test users
|
||||
fix_search_engines_for_user("apitest_user", "apitest_pass123")
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add research_mode column to existing databases.
|
||||
This script adds the missing research_mode column to TokenUsage and SearchCall tables.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from local_deep_research.config.paths import get_user_data_directory
|
||||
|
||||
|
||||
def add_research_mode_column(db_path: str):
|
||||
"""Add research_mode column to tables if it doesn't exist."""
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
except Exception:
|
||||
logger.exception(f"Error connecting to {db_path}")
|
||||
return
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if column exists in token_usage
|
||||
cursor.execute("PRAGMA table_info(token_usage)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if "research_mode" not in columns:
|
||||
logger.info(
|
||||
f"Adding research_mode column to token_usage in {db_path}"
|
||||
)
|
||||
cursor.execute(
|
||||
"ALTER TABLE token_usage ADD COLUMN research_mode VARCHAR(50)"
|
||||
)
|
||||
conn.commit()
|
||||
else:
|
||||
logger.info(
|
||||
f"research_mode column already exists in token_usage in {db_path}"
|
||||
)
|
||||
|
||||
# Check if column exists in search_calls
|
||||
cursor.execute("PRAGMA table_info(search_calls)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if "research_mode" not in columns:
|
||||
logger.info(
|
||||
f"Adding research_mode column to search_calls in {db_path}"
|
||||
)
|
||||
cursor.execute(
|
||||
"ALTER TABLE search_calls ADD COLUMN research_mode VARCHAR(50)"
|
||||
)
|
||||
conn.commit()
|
||||
else:
|
||||
logger.info(
|
||||
f"research_mode column already exists in search_calls in {db_path}"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully updated {db_path}")
|
||||
except Exception:
|
||||
logger.exception(f"Error updating {db_path}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def migrate_all_user_databases():
|
||||
"""Migrate all user databases to add research_mode column."""
|
||||
# Get base data directory
|
||||
from local_deep_research.config.paths import get_data_directory
|
||||
|
||||
base_dir = get_data_directory()
|
||||
|
||||
# Look for user directories
|
||||
user_dirs = []
|
||||
if base_dir.exists():
|
||||
for item in base_dir.iterdir():
|
||||
if item.is_dir() and item.name.startswith("user_"):
|
||||
user_dirs.append(item)
|
||||
|
||||
if not user_dirs:
|
||||
logger.warning("No user directories found")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(user_dirs)} user directories to migrate")
|
||||
|
||||
for user_dir in user_dirs:
|
||||
db_path = user_dir / f"{user_dir.name}_encrypted.db"
|
||||
if db_path.exists():
|
||||
logger.info(f"Migrating database for {user_dir.name}")
|
||||
add_research_mode_column(str(db_path))
|
||||
else:
|
||||
logger.warning(f"No database found in {user_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Enable debug logging
|
||||
logger.remove()
|
||||
# diagnose=False: don't dump frame-local credentials in tracebacks
|
||||
# (#4185 / #4384). This script touches user databases and SQLCipher
|
||||
# passwords live in frame locals during migration.
|
||||
logger.add(sys.stdout, level="INFO", diagnose=False)
|
||||
|
||||
# Allow unencrypted for testing
|
||||
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "true"
|
||||
|
||||
# Run migration
|
||||
migrate_all_user_databases()
|
||||
|
||||
# Also migrate specific test user if needed
|
||||
if len(sys.argv) > 1:
|
||||
username = sys.argv[1]
|
||||
user_dir = get_user_data_directory(username)
|
||||
db_path = user_dir / f"{user_dir.name}_encrypted.db"
|
||||
if db_path.exists():
|
||||
logger.info(f"Migrating specific user: {username}")
|
||||
add_research_mode_column(str(db_path))
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Populate search engine settings in the database
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from local_deep_research.database.models import Setting
|
||||
from local_deep_research.utilities.db_utils import get_db_session
|
||||
|
||||
|
||||
def populate_search_engines():
|
||||
"""Populate search engine settings in the database"""
|
||||
|
||||
# Define search engines with their properties
|
||||
search_engines = {
|
||||
"searxng": {
|
||||
"display_name": "SearXNG",
|
||||
"description": "Privacy-focused metasearch engine",
|
||||
"strengths": ["Privacy", "No tracking", "Multiple sources"],
|
||||
},
|
||||
"google": {
|
||||
"display_name": "Google",
|
||||
"description": "Google search engine",
|
||||
"strengths": ["Comprehensive", "Fast", "Relevant results"],
|
||||
},
|
||||
"bing": {
|
||||
"display_name": "Bing",
|
||||
"description": "Microsoft Bing search engine",
|
||||
"strengths": ["Good for technical queries", "Image search", "News"],
|
||||
},
|
||||
"duckduckgo": {
|
||||
"display_name": "DuckDuckGo",
|
||||
"description": "Privacy-focused search engine",
|
||||
"strengths": ["Privacy", "No tracking", "Instant answers"],
|
||||
},
|
||||
"wikipedia": {
|
||||
"display_name": "Wikipedia",
|
||||
"description": "Wikipedia search",
|
||||
"strengths": ["Encyclopedic content", "Reliable", "Detailed"],
|
||||
},
|
||||
"arxiv": {
|
||||
"display_name": "arXiv",
|
||||
"description": "Scientific paper repository",
|
||||
"strengths": ["Academic papers", "Research", "Preprints"],
|
||||
},
|
||||
"pubmed": {
|
||||
"display_name": "PubMed",
|
||||
"description": "Medical research database",
|
||||
"strengths": ["Medical research", "Clinical studies", "Healthcare"],
|
||||
},
|
||||
"semantic_scholar": {
|
||||
"display_name": "Semantic Scholar",
|
||||
"description": "AI-powered research tool",
|
||||
"strengths": [
|
||||
"Academic search",
|
||||
"Citation analysis",
|
||||
"AI insights",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
session = get_db_session()
|
||||
|
||||
try:
|
||||
# Add web search engines
|
||||
for engine_name, props in search_engines.items():
|
||||
# Display name
|
||||
display_setting = Setting(
|
||||
key=f"search.engine.web.{engine_name}.display_name",
|
||||
value=props["display_name"],
|
||||
type="SEARCH",
|
||||
category="search",
|
||||
name=f"{engine_name} Display Name",
|
||||
description=f"Display name for {engine_name}",
|
||||
)
|
||||
existing = (
|
||||
session.query(Setting)
|
||||
.filter_by(key=display_setting.key)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
session.add(display_setting)
|
||||
logger.info(f"Added {display_setting.key}")
|
||||
|
||||
# Description
|
||||
desc_setting = Setting(
|
||||
key=f"search.engine.web.{engine_name}.description",
|
||||
value=props["description"],
|
||||
type="SEARCH",
|
||||
category="search",
|
||||
name=f"{engine_name} Description",
|
||||
description=f"Description for {engine_name}",
|
||||
)
|
||||
existing = (
|
||||
session.query(Setting).filter_by(key=desc_setting.key).first()
|
||||
)
|
||||
if not existing:
|
||||
session.add(desc_setting)
|
||||
logger.info(f"Added {desc_setting.key}")
|
||||
|
||||
# Strengths
|
||||
strengths_setting = Setting(
|
||||
key=f"search.engine.web.{engine_name}.strengths",
|
||||
value=props["strengths"],
|
||||
type="SEARCH",
|
||||
category="search",
|
||||
name=f"{engine_name} Strengths",
|
||||
description=f"Strengths of {engine_name}",
|
||||
)
|
||||
existing = (
|
||||
session.query(Setting)
|
||||
.filter_by(key=strengths_setting.key)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
session.add(strengths_setting)
|
||||
logger.info(f"Added {strengths_setting.key}")
|
||||
|
||||
# Commit all changes
|
||||
session.commit()
|
||||
logger.info("Successfully populated search engine settings")
|
||||
|
||||
# Verify by querying
|
||||
search_settings = (
|
||||
session.query(Setting)
|
||||
.filter(
|
||||
Setting.type == "SEARCH", Setting.key.contains("display_name")
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
logger.info(f"Total search engine settings: {len(search_settings)}")
|
||||
for setting in search_settings:
|
||||
logger.info(f" {setting.key}: {setting.value}")
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error populating search engines")
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Need to set this for test user database
|
||||
os.environ["LDR_CURRENT_USER"] = "testuser"
|
||||
populate_search_engines()
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Runner script for API tests.
|
||||
|
||||
Usage:
|
||||
python run_api_tests.py # Run all API tests
|
||||
python run_api_tests.py search # Run search engines test only
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all API tests."""
|
||||
print("Running all API tests...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "test_all_apis.py"], cwd=str(Path(__file__).parent)
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_search_engines_test():
|
||||
"""Run search engines API test."""
|
||||
print("Running search engines API test...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "test_search_engines_api.py"],
|
||||
cwd=str(Path(__file__).parent),
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "search":
|
||||
return run_search_engines_test()
|
||||
return run_all_tests()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Run basic API tests that are known to work.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
os.environ["LDR_BOOTSTRAP_ALLOW_UNENCRYPTED"] = "true"
|
||||
|
||||
from loguru import logger
|
||||
from test_comprehensive_apis import (
|
||||
test_auth_apis,
|
||||
test_history_apis,
|
||||
test_settings_apis,
|
||||
)
|
||||
|
||||
|
||||
def run_working_tests():
|
||||
"""Run only the APIs that are known to work."""
|
||||
logger.info("Running basic API tests...\n")
|
||||
|
||||
try:
|
||||
# Initialize tester and authenticate
|
||||
tester = test_auth_apis()
|
||||
|
||||
# Run working test suites
|
||||
test_settings_apis(tester)
|
||||
test_history_apis(tester)
|
||||
|
||||
logger.info("\n✅ Basic API tests passed successfully!")
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Test failed")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Setup logging
|
||||
logger.remove()
|
||||
# diagnose=False: don't dump frame-local credentials in tracebacks
|
||||
# (#4185 / #4384). API tests hold api_key and password locals.
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level="INFO",
|
||||
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
success = run_working_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Comprehensive API Tests for Local Deep Research.
|
||||
|
||||
This test suite checks all API endpoints for proper authentication,
|
||||
response formats, and basic functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestAllAPIs:
|
||||
"""Test suite for all API endpoints."""
|
||||
|
||||
def test_auth_apis(self, authenticated_client):
|
||||
"""Test authentication APIs."""
|
||||
# Check auth status
|
||||
response = authenticated_client.get("/auth/check")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["authenticated"] is True
|
||||
assert "username" in data
|
||||
|
||||
# Test integrity check
|
||||
response = authenticated_client.get("/auth/integrity-check")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "integrity" in data
|
||||
assert "username" in data
|
||||
|
||||
def test_settings_apis(self, authenticated_client):
|
||||
"""Test settings APIs."""
|
||||
# Get all settings
|
||||
response = authenticated_client.get("/settings/api")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "success"
|
||||
assert "settings" in data
|
||||
|
||||
# Get categories
|
||||
response = authenticated_client.get("/settings/api/categories")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "categories" in data
|
||||
|
||||
# Get types
|
||||
response = authenticated_client.get("/settings/api/types")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "types" in data
|
||||
|
||||
# Get available models
|
||||
response = authenticated_client.get("/settings/api/available-models")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert (
|
||||
"providers" in data or "models" in data
|
||||
) # API might return either
|
||||
|
||||
# Get available search engines - THIS IS FAILING
|
||||
logger.info("Testing search engines API...")
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/available-search-engines"
|
||||
)
|
||||
logger.info(f"Search engines response: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Search engines error: {response.data}")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "engines" in data or "engine_options" in data
|
||||
|
||||
# Get warnings
|
||||
response = authenticated_client.get("/settings/api/warnings")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert isinstance(
|
||||
data, (list, dict)
|
||||
) # API might return list or dict with warnings
|
||||
|
||||
# Test get/set specific setting
|
||||
response = authenticated_client.get("/settings/api/llm.temperature")
|
||||
if response.status_code == 404:
|
||||
# Setting doesn't exist, create it
|
||||
response = authenticated_client.put(
|
||||
"/settings/api/llm.temperature",
|
||||
json={"value": 0.7},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
# Get data location
|
||||
response = authenticated_client.get("/settings/api/data-location")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert (
|
||||
"data_directory" in data or "data_dir" in data
|
||||
) # API might return either
|
||||
assert "is_custom" in data
|
||||
|
||||
def test_research_apis(self, authenticated_client):
|
||||
"""Test research APIs."""
|
||||
# Get research history
|
||||
response = authenticated_client.get("/history/api")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "items" in data or "history" in data # API might return either
|
||||
|
||||
# Start a test research
|
||||
research_data = {
|
||||
"query": "API test research query",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"search_engines": ["searxng"],
|
||||
"local_context": 2000,
|
||||
"web_context": 2000,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/research/api/start",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# The research start API might fail in test environment due to
|
||||
# missing LLM configuration or other issues. We'll accept any
|
||||
# response as long as it's a valid HTTP response.
|
||||
assert response.status_code in [200, 400, 404, 500]
|
||||
|
||||
# If successful, test the other endpoints
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
if "research_id" in data:
|
||||
research_id = data["research_id"]
|
||||
|
||||
# Check status
|
||||
response = authenticated_client.get(
|
||||
f"/research/api/status/{research_id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Terminate research
|
||||
response = authenticated_client.post(
|
||||
f"/research/api/terminate/{research_id}"
|
||||
)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
def test_metrics_apis(self, authenticated_client):
|
||||
"""Test metrics APIs."""
|
||||
# Get metrics summary
|
||||
response = authenticated_client.get("/metrics/api/metrics")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "metrics" in data or "summary" in data # API might return either
|
||||
|
||||
# Get enhanced metrics
|
||||
response = authenticated_client.get("/metrics/api/metrics/enhanced")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get star reviews
|
||||
response = authenticated_client.get("/metrics/api/star-reviews")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get pricing info
|
||||
response = authenticated_client.get("/metrics/api/pricing")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "pricing" in data or "models" in data # API might return either
|
||||
|
||||
# Get cost analytics
|
||||
response = authenticated_client.get("/metrics/api/cost-analytics")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_benchmark_apis(self, authenticated_client):
|
||||
"""Test benchmark APIs."""
|
||||
# Get benchmark history
|
||||
response = authenticated_client.get("/benchmark/api/history")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "runs" in data or "history" in data # API might return either
|
||||
|
||||
# Get saved configs
|
||||
response = authenticated_client.get("/benchmark/api/configs")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "configs" in data
|
||||
|
||||
# Check running benchmark
|
||||
response = authenticated_client.get("/benchmark/api/running")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert (
|
||||
"is_running" in data or "success" in data
|
||||
) # API might return either
|
||||
|
||||
# Validate config
|
||||
test_config = {
|
||||
"name": "Test Config",
|
||||
"queries": ["test query"],
|
||||
"models": ["gpt-3.5-turbo"],
|
||||
"search_engines": ["searxng"],
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
"/benchmark/api/validate-config",
|
||||
json=test_config,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_history_apis(self, authenticated_client):
|
||||
"""Test history APIs."""
|
||||
response = authenticated_client.get("/history/api")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "items" in data or "history" in data # API might return either
|
||||
# Get the actual history list
|
||||
history_data = data.get("items", data.get("history", []))
|
||||
assert isinstance(history_data, list)
|
||||
|
||||
def test_config_apis(self, authenticated_client):
|
||||
"""Test configuration APIs."""
|
||||
# Get current config - api_bp is registered with /research/api prefix
|
||||
response = authenticated_client.get(
|
||||
"/research/api/settings/current-config"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "success" in data # API returns success status
|
||||
if data.get("success"):
|
||||
assert "config" in data # Config is nested under config key
|
||||
|
||||
# Get public config - This endpoint may not exist
|
||||
response = authenticated_client.get("/api/config")
|
||||
if response.status_code == 404:
|
||||
# Endpoint doesn't exist, skip test
|
||||
pass
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "version" in data or "available_models" in data
|
||||
|
||||
def test_health_check_apis(self, authenticated_client):
|
||||
"""Test health check APIs."""
|
||||
# Check Ollama status - the api_bp is registered with /research/api prefix
|
||||
response = authenticated_client.get("/research/api/check/ollama_status")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert (
|
||||
"running" in data or "available" in data
|
||||
) # API might return either
|
||||
|
||||
# Check Ollama model
|
||||
response = authenticated_client.get("/research/api/check/ollama_model")
|
||||
# This might return 400 if no model specified
|
||||
assert response.status_code in [200, 400]
|
||||
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive test suite that tests ALL API endpoints in the codebase.
|
||||
This ensures we have coverage for every single endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestAllEndpoints:
|
||||
"""Comprehensive API test suite using Flask test client."""
|
||||
|
||||
def test_all_auth_endpoints(self, authenticated_client):
|
||||
"""Test ALL authentication endpoints."""
|
||||
logger.info("\n=== Testing ALL Authentication Endpoints ===")
|
||||
|
||||
# Test endpoints
|
||||
endpoints = [
|
||||
("GET", "/auth/check", None, 200),
|
||||
("GET", "/auth/integrity-check", None, 200),
|
||||
]
|
||||
|
||||
for method, endpoint, data, expected_status in endpoints:
|
||||
if method == "GET":
|
||||
response = authenticated_client.get(endpoint)
|
||||
else:
|
||||
response = authenticated_client.post(
|
||||
endpoint,
|
||||
json=data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == expected_status, (
|
||||
f"{endpoint} failed: {response.data}"
|
||||
)
|
||||
|
||||
logger.info("✅ All Auth endpoints tested")
|
||||
|
||||
def test_all_settings_endpoints(self, authenticated_client):
|
||||
"""Test ALL settings endpoints."""
|
||||
logger.info("\n=== Testing ALL Settings Endpoints ===")
|
||||
|
||||
# Basic GET endpoints
|
||||
get_endpoints = [
|
||||
"/settings/api",
|
||||
"/settings/api/categories",
|
||||
"/settings/api/types",
|
||||
"/settings/api/ui_elements",
|
||||
"/settings/api/available-models",
|
||||
"/settings/api/available-search-engines",
|
||||
"/settings/api/warnings",
|
||||
"/settings/api/ollama-status",
|
||||
"/settings/api/rate-limiting/status",
|
||||
"/settings/api/data-location",
|
||||
]
|
||||
|
||||
for endpoint in get_endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
assert response.status_code == 200, (
|
||||
f"{endpoint} failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test bulk get
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/bulk?keys=llm.provider,llm.model"
|
||||
)
|
||||
assert response.status_code == 200, f"Bulk get failed: {response.data}"
|
||||
|
||||
# Test setting CRUD (use an allow-listed prefix so the
|
||||
# namespace gate permits creation of this throwaway key).
|
||||
test_key = "llm.endpoint_test_value"
|
||||
test_value = {"value": "test_endpoint_123", "editable": True}
|
||||
|
||||
# Create/Update
|
||||
response = authenticated_client.put(
|
||||
f"/settings/api/{test_key}",
|
||||
json=test_value,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [200, 201], (
|
||||
f"Create setting failed: {response.data}"
|
||||
)
|
||||
|
||||
# Get specific setting
|
||||
data = json.loads(response.data)
|
||||
if isinstance(data, dict) and "setting" in data:
|
||||
actual_key = data["setting"].get("key", test_key)
|
||||
else:
|
||||
actual_key = test_key
|
||||
|
||||
response = authenticated_client.get(f"/settings/api/{actual_key}")
|
||||
assert response.status_code == 200, (
|
||||
f"Get setting failed: {response.data}"
|
||||
)
|
||||
|
||||
# Delete
|
||||
response = authenticated_client.delete(f"/settings/api/{actual_key}")
|
||||
assert response.status_code in [200, 204, 404], (
|
||||
f"Delete setting failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test rate limiting reset
|
||||
response = authenticated_client.post(
|
||||
"/settings/api/rate-limiting/engines/test/reset"
|
||||
)
|
||||
assert response.status_code in [200, 404], (
|
||||
f"Rate limit reset failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test cleanup
|
||||
response = authenticated_client.post(
|
||||
"/settings/api/rate-limiting/cleanup"
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"Rate limit cleanup failed: {response.data}"
|
||||
)
|
||||
|
||||
logger.info("✅ All Settings endpoints tested")
|
||||
|
||||
def test_all_research_endpoints(self, authenticated_client):
|
||||
"""Test ALL research-related endpoints."""
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
logger.info("\n=== Testing ALL Research Endpoints ===")
|
||||
|
||||
# Set up logging.
|
||||
try:
|
||||
logger.level("MILESTONE", no=26, color="<magenta><bold>")
|
||||
except (TypeError, ValueError):
|
||||
pass # Level already registered
|
||||
|
||||
# Test history endpoint first
|
||||
response = authenticated_client.get("/history/api")
|
||||
assert response.status_code == 200, (
|
||||
f"History API failed: {response.data}"
|
||||
)
|
||||
|
||||
# Start research via main endpoint
|
||||
research_data = {
|
||||
"query": "Test all endpoints comprehensive",
|
||||
"mode": "quick",
|
||||
"model_provider": "OLLAMA",
|
||||
"model": "llama2",
|
||||
"search_engine": "searxng",
|
||||
"max_results": 5,
|
||||
"time_period": "y",
|
||||
"iterations": 1,
|
||||
"questions_per_iteration": 2,
|
||||
"strategy": "rapid",
|
||||
}
|
||||
|
||||
# Test /api/start_research endpoint
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"Start research failed: {response.data}"
|
||||
)
|
||||
data = json.loads(response.data)
|
||||
assert "research_id" in data
|
||||
research_id = data["research_id"]
|
||||
|
||||
# Give it a moment to start
|
||||
time.sleep(0.5) # allow: unmarked-sleep
|
||||
|
||||
# Test various research endpoints
|
||||
research_endpoints = [
|
||||
("GET", f"/api/research/{research_id}", None, 200),
|
||||
("GET", f"/api/research/{research_id}/status", None, 200),
|
||||
("GET", f"/research/api/status/{research_id}", None, 200),
|
||||
("GET", f"/history/status/{research_id}", None, 200),
|
||||
("GET", f"/history/details/{research_id}", None, 200),
|
||||
("GET", f"/history/log_count/{research_id}", None, 200),
|
||||
]
|
||||
|
||||
for method, endpoint, req_data, expected_status in research_endpoints:
|
||||
if method == "GET":
|
||||
response = authenticated_client.get(endpoint)
|
||||
else:
|
||||
response = authenticated_client.post(
|
||||
endpoint,
|
||||
json=req_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
# Some endpoints might return 404 if research is still initializing
|
||||
assert response.status_code in [expected_status, 404], (
|
||||
f"{endpoint} failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test research API routes
|
||||
response = authenticated_client.get(
|
||||
"/research/api/settings/current-config"
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"Current config failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test Ollama checks
|
||||
response = authenticated_client.get("/research/api/check/ollama_status")
|
||||
assert response.status_code == 200, (
|
||||
f"Ollama status check failed: {response.data}"
|
||||
)
|
||||
|
||||
# Terminate research
|
||||
response = authenticated_client.post(f"/api/terminate/{research_id}")
|
||||
assert response.status_code in [200, 400, 404], (
|
||||
f"Terminate research failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test alternate terminate endpoint
|
||||
response = authenticated_client.post(
|
||||
f"/research/api/terminate/{research_id}"
|
||||
)
|
||||
assert response.status_code in [200, 400, 404], (
|
||||
f"API terminate research failed: {response.data}"
|
||||
)
|
||||
|
||||
logger.info("✅ All Research endpoints tested")
|
||||
|
||||
return research_id
|
||||
|
||||
def test_all_metrics_endpoints(self, authenticated_client):
|
||||
"""Test ALL metrics endpoints."""
|
||||
logger.info("\n=== Testing ALL Metrics Endpoints ===")
|
||||
|
||||
# Basic metrics endpoints
|
||||
metrics_endpoints = [
|
||||
"/metrics/api/metrics",
|
||||
"/metrics/api/metrics/enhanced",
|
||||
"/metrics/api/pricing",
|
||||
"/metrics/api/cost-analytics",
|
||||
"/metrics/api/star-reviews",
|
||||
"/metrics/api/rate-limiting",
|
||||
"/metrics/api/rate-limiting/current",
|
||||
]
|
||||
|
||||
for endpoint in metrics_endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
assert response.status_code == 200, (
|
||||
f"{endpoint} failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test specific model pricing
|
||||
response = authenticated_client.get(
|
||||
"/metrics/api/pricing/gpt-3.5-turbo"
|
||||
)
|
||||
assert response.status_code in [200, 404], (
|
||||
f"Model pricing failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test cost calculation
|
||||
cost_data = {
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 500,
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
"/metrics/api/cost-calculation",
|
||||
json=cost_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"Cost calculation failed: {response.data}"
|
||||
)
|
||||
|
||||
logger.info("✅ All Metrics endpoints tested")
|
||||
|
||||
def test_all_benchmark_endpoints(self, authenticated_client):
|
||||
"""Test ALL benchmark endpoints."""
|
||||
logger.info("\n=== Testing ALL Benchmark Endpoints ===")
|
||||
|
||||
# Basic benchmark endpoints
|
||||
benchmark_endpoints = [
|
||||
"/benchmark/api/history",
|
||||
"/benchmark/api/configs",
|
||||
"/benchmark/api/running",
|
||||
"/benchmark/api/search-quality",
|
||||
]
|
||||
|
||||
for endpoint in benchmark_endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
assert response.status_code == 200, (
|
||||
f"{endpoint} failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test config validation
|
||||
test_config = {
|
||||
"name": "Test Benchmark Config",
|
||||
"queries": ["test query 1", "test query 2"],
|
||||
"models": ["gpt-3.5-turbo"],
|
||||
"search_engines": ["searxng"],
|
||||
"iterations": 1,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/benchmark/api/validate-config",
|
||||
json=test_config,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200, (
|
||||
f"Validate config failed: {response.data}"
|
||||
)
|
||||
|
||||
# Test simple benchmark start (but don't actually run it)
|
||||
simple_benchmark = {
|
||||
"query": "test benchmark",
|
||||
"models": ["gpt-3.5-turbo"],
|
||||
"search_engines": ["searxng"],
|
||||
}
|
||||
|
||||
# Note: We won't actually start a benchmark as it's resource intensive
|
||||
# Just verify the endpoint exists
|
||||
response = authenticated_client.post(
|
||||
"/benchmark/api/start-simple",
|
||||
json=simple_benchmark,
|
||||
content_type="application/json",
|
||||
)
|
||||
# Accept 409 (conflict) if another benchmark is running
|
||||
assert response.status_code in [200, 409, 400], (
|
||||
f"Start simple benchmark status: {response.status_code}, {response.data}"
|
||||
)
|
||||
|
||||
logger.info("✅ All Benchmark endpoints tested")
|
||||
|
||||
def test_all_history_endpoints(self, authenticated_client):
|
||||
"""Test ALL history endpoints."""
|
||||
logger.info("\n=== Testing ALL History Endpoints ===")
|
||||
|
||||
# Basic history endpoint
|
||||
response = authenticated_client.get("/history/api")
|
||||
assert response.status_code == 200, (
|
||||
f"History API failed: {response.data}"
|
||||
)
|
||||
|
||||
logger.info("✅ All History endpoints tested")
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
def test_all_api_v1_endpoints(self, authenticated_client):
|
||||
"""Test ALL API v1 endpoints."""
|
||||
logger.info("\n=== Testing ALL API v1 Endpoints ===")
|
||||
|
||||
# Health check (doesn't require auth)
|
||||
response = authenticated_client.get("/api/v1/health")
|
||||
assert response.status_code == 200, (
|
||||
f"Health check failed: {response.data}"
|
||||
)
|
||||
|
||||
# API documentation
|
||||
response = authenticated_client.get("/api/v1/")
|
||||
assert response.status_code == 200, f"API docs failed: {response.data}"
|
||||
|
||||
# Quick summary
|
||||
summary_data = {"query": "What is machine learning?", "max_tokens": 100}
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json=summary_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [200, 404, 500], (
|
||||
f"Quick summary status: {response.status_code}"
|
||||
)
|
||||
|
||||
# Generate report
|
||||
report_data = {
|
||||
"query": "Test report generation",
|
||||
"research_type": "comprehensive",
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/generate_report",
|
||||
json=report_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [200, 404, 500], (
|
||||
f"Generate report status: {response.status_code}"
|
||||
)
|
||||
|
||||
# Analyze documents. The endpoint searches a named local collection;
|
||||
# it does not accept an inline "documents" key (that now yields a 400).
|
||||
doc_data = {
|
||||
"query": "Summarize this document",
|
||||
"collection_name": "test_collection",
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json=doc_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [200, 404, 500], (
|
||||
f"Analyze documents status: {response.status_code}"
|
||||
)
|
||||
|
||||
logger.info("✅ All API v1 endpoints tested")
|
||||
|
||||
def test_miscellaneous_endpoints(self, authenticated_client):
|
||||
"""Test miscellaneous endpoints not covered in other categories."""
|
||||
logger.info("\n=== Testing Miscellaneous Endpoints ===")
|
||||
|
||||
# Test static file serving
|
||||
response = authenticated_client.get("/static/css/style.css")
|
||||
assert response.status_code in [200, 404], "Static file endpoint failed"
|
||||
|
||||
# Test favicon
|
||||
response = authenticated_client.get("/favicon.ico")
|
||||
assert response.status_code in [200, 404], "Favicon endpoint failed"
|
||||
|
||||
# Test pages that should exist
|
||||
page_endpoints = [
|
||||
"/", # Home page
|
||||
"/history", # History page
|
||||
"/settings", # Settings page
|
||||
"/metrics/", # Metrics dashboard
|
||||
"/benchmark/", # Benchmark dashboard
|
||||
]
|
||||
|
||||
for endpoint in page_endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
assert response.status_code == 200, (
|
||||
f"Page {endpoint} failed with status {response.status_code}"
|
||||
)
|
||||
|
||||
logger.info("✅ All miscellaneous endpoints tested")
|
||||
|
||||
def test_error_handling(self, authenticated_client):
|
||||
"""Test error handling for invalid endpoints and methods."""
|
||||
logger.info("\n=== Testing Error Handling ===")
|
||||
|
||||
# Test non-existent endpoint
|
||||
response = authenticated_client.get("/api/does-not-exist")
|
||||
assert response.status_code == 404, (
|
||||
f"Non-existent endpoint should return 404, got {response.status_code}"
|
||||
)
|
||||
|
||||
# Test wrong method
|
||||
response = authenticated_client.delete("/auth/check")
|
||||
assert response.status_code in [404, 405], (
|
||||
f"Wrong method should return 404/405, got {response.status_code}"
|
||||
)
|
||||
|
||||
# Test invalid JSON
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
data="invalid json",
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [400, 500], (
|
||||
f"Invalid JSON should return 400/500, got {response.status_code}"
|
||||
)
|
||||
|
||||
logger.info("✅ Error handling tested")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Basic API tests - only test endpoints that should respond quickly.
|
||||
Focus on verifying the API is working without doing actual research.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from flask import json
|
||||
|
||||
|
||||
class TestBasicAPI:
|
||||
"""Test basic API functionality."""
|
||||
|
||||
def test_health_check(self, authenticated_client):
|
||||
"""Test health check endpoint."""
|
||||
response = authenticated_client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "ok"
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_api_documentation(self, authenticated_client):
|
||||
"""Test API documentation endpoint."""
|
||||
response = authenticated_client.get("/api/v1/")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["api_version"] == "v1"
|
||||
assert "endpoints" in data
|
||||
assert len(data["endpoints"]) >= 3
|
||||
|
||||
def test_error_handling(self, authenticated_client):
|
||||
"""Test error handling for malformed requests."""
|
||||
# Test missing query parameter
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
assert "required" in data["error"].lower()
|
||||
|
||||
# Test missing collection_name
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json={"query": "test"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
def test_api_structure(
|
||||
self, authenticated_client, setup_database_for_all_tests
|
||||
):
|
||||
"""Test that API accepts properly formatted requests."""
|
||||
payload = {
|
||||
"query": "test",
|
||||
"search_tool": "wikipedia",
|
||||
"iterations": 1,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
print(
|
||||
f"\n[DEBUG] Sending request to /api/v1/quick_summary with payload: {payload}"
|
||||
)
|
||||
|
||||
# Send request with proper format
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
print(f"[DEBUG] Response status code: {response.status_code}")
|
||||
print(
|
||||
f"[DEBUG] Response data: {response.data.decode()[:500]}"
|
||||
) # First 500 chars
|
||||
|
||||
if response.status_code == 500:
|
||||
# Print full error details for debugging
|
||||
try:
|
||||
error_data = json.loads(response.data)
|
||||
print(f"[DEBUG] Error response JSON: {error_data}")
|
||||
except Exception:
|
||||
print(f"[DEBUG] Raw error response: {response.data.decode()}")
|
||||
|
||||
# The API should accept the request format
|
||||
# It might return 200 with processing started, or 400 if there's a validation error
|
||||
assert response.status_code in [200, 202, 400]
|
||||
|
||||
if response.status_code == 400:
|
||||
# Check that error message is informative
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
def test_unauthenticated_access(self, client):
|
||||
"""Test that unauthenticated requests are rejected."""
|
||||
response = client.get("/api/v1/health")
|
||||
# API endpoints might allow unauthenticated access for health checks
|
||||
# or might redirect to login
|
||||
assert response.status_code in [200, 302, 401]
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test API response contracts.
|
||||
|
||||
These tests verify that API responses have the expected structure.
|
||||
Breaking these contracts will break client integrations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestResearchStatusValues:
|
||||
"""
|
||||
Verify that research status values are consistent.
|
||||
|
||||
Clients depend on these exact string values.
|
||||
"""
|
||||
|
||||
def test_research_status_enum_values(self):
|
||||
"""
|
||||
Verify ResearchStatus enum has expected values.
|
||||
|
||||
Clients check for these exact string values in responses.
|
||||
Changing them will break client code.
|
||||
"""
|
||||
from local_deep_research.database.models.research import (
|
||||
ResearchStatus,
|
||||
)
|
||||
|
||||
# These are the status values clients depend on
|
||||
expected_values = {
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"suspended",
|
||||
}
|
||||
|
||||
actual_values = {status.value for status in ResearchStatus}
|
||||
|
||||
# Check no expected values were removed
|
||||
missing = expected_values - actual_values
|
||||
assert not missing, (
|
||||
f"ResearchStatus is missing expected values: {missing}\n"
|
||||
"Clients depend on these status values.\n"
|
||||
"Removing them will break client integrations."
|
||||
)
|
||||
|
||||
def test_research_mode_enum_values(self):
|
||||
"""
|
||||
Verify ResearchMode enum has expected values.
|
||||
|
||||
Clients use these values when starting research.
|
||||
"""
|
||||
from local_deep_research.database.models.research import (
|
||||
ResearchMode,
|
||||
)
|
||||
|
||||
# These are the mode values clients depend on
|
||||
expected_values = {
|
||||
"quick",
|
||||
"detailed",
|
||||
}
|
||||
|
||||
actual_values = {mode.value for mode in ResearchMode}
|
||||
|
||||
missing = expected_values - actual_values
|
||||
assert not missing, (
|
||||
f"ResearchMode is missing expected values: {missing}\n"
|
||||
"Clients depend on these mode values."
|
||||
)
|
||||
|
||||
|
||||
class TestResponseStructures:
|
||||
"""
|
||||
Verify that models have expected columns for API responses.
|
||||
|
||||
These are the fields that clients depend on.
|
||||
"""
|
||||
|
||||
def test_research_has_required_columns(self):
|
||||
"""
|
||||
Verify Research model has required columns for API responses.
|
||||
|
||||
These columns are serialized in API responses.
|
||||
"""
|
||||
from local_deep_research.database.models.research import Research
|
||||
|
||||
required_columns = {
|
||||
"id",
|
||||
"query",
|
||||
"status",
|
||||
"mode",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
actual_columns = set(Research.__table__.columns.keys())
|
||||
|
||||
missing = required_columns - actual_columns
|
||||
assert not missing, (
|
||||
f"Research model is missing required columns: {missing}\n"
|
||||
"These columns are expected in API responses."
|
||||
)
|
||||
|
||||
def test_user_settings_has_required_columns(self):
|
||||
"""
|
||||
Verify UserSettings model has required columns for API responses.
|
||||
"""
|
||||
from local_deep_research.database.models import UserSettings
|
||||
|
||||
required_columns = {"id", "key", "value", "category"}
|
||||
|
||||
actual_columns = set(UserSettings.__table__.columns.keys())
|
||||
|
||||
missing = required_columns - actual_columns
|
||||
assert not missing, (
|
||||
f"UserSettings model is missing required columns: {missing}\n"
|
||||
"These columns are expected in settings API responses."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
pytest-compatible API tests for REST API endpoints.
|
||||
Tests basic functionality and programmatic access integration.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class TestRestAPIBasic:
|
||||
"""Basic tests for REST API endpoints."""
|
||||
|
||||
def test_health_check(self, authenticated_client):
|
||||
"""Test the health check endpoint returns OK status."""
|
||||
response = authenticated_client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "ok"
|
||||
assert "timestamp" in data
|
||||
assert isinstance(data["timestamp"], (int, float))
|
||||
|
||||
def test_api_documentation(self, authenticated_client):
|
||||
"""Test the API documentation endpoint returns proper structure."""
|
||||
response = authenticated_client.get("/api/v1/")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data["api_version"] == "v1"
|
||||
assert "description" in data
|
||||
assert "endpoints" in data
|
||||
assert len(data["endpoints"]) >= 3
|
||||
|
||||
# Verify endpoint structure
|
||||
endpoints = data["endpoints"]
|
||||
if isinstance(endpoints, list):
|
||||
# For list format, just check basic fields
|
||||
for endpoint in endpoints:
|
||||
assert "path" in endpoint or "endpoint" in endpoint
|
||||
assert "method" in endpoint or "methods" in endpoint
|
||||
assert "description" in endpoint
|
||||
# auth_required field is optional in API v1
|
||||
else:
|
||||
# For dict format
|
||||
for endpoint in endpoints.values():
|
||||
assert "path" in endpoint
|
||||
assert "methods" in endpoint
|
||||
assert "description" in endpoint
|
||||
assert "requires_auth" in endpoint
|
||||
|
||||
def test_quick_summary_validation(self, authenticated_client):
|
||||
"""Test quick_summary endpoint validates required fields."""
|
||||
# Test missing query
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
assert "query" in data["error"].lower()
|
||||
|
||||
def test_analyze_documents_validation(self, authenticated_client):
|
||||
"""Test analyze_documents endpoint validates required fields."""
|
||||
# Test missing collection_name
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json={"query": "test query"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
assert "collection_name" in data["error"].lower()
|
||||
|
||||
# Test missing query
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json={"collection_name": "test_collection"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
assert "query" in data["error"].lower()
|
||||
|
||||
def test_generate_report_validation(self, authenticated_client):
|
||||
"""Test generate_report endpoint validates required fields."""
|
||||
# Test missing research_id or query
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/generate_report",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
# Check for either research_id or query in error message
|
||||
error_msg = data["error"].lower()
|
||||
assert (
|
||||
"research_id" in error_msg
|
||||
or "query" in error_msg
|
||||
or "required" in error_msg
|
||||
)
|
||||
|
||||
def test_error_response_format(self, authenticated_client):
|
||||
"""Test that error responses follow consistent format."""
|
||||
# Trigger a 404 error
|
||||
response = authenticated_client.get("/api/v1/nonexistent_endpoint")
|
||||
assert response.status_code == 404
|
||||
|
||||
if response.content_type == "application/json":
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data or "message" in data
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test the browser's research creation endpoint specifically
|
||||
"""
|
||||
|
||||
from flask import json
|
||||
|
||||
|
||||
class TestBrowserEndpoint:
|
||||
"""Test browser-specific endpoints."""
|
||||
|
||||
def test_start_research_endpoint(self, authenticated_client):
|
||||
"""Test the browser endpoint /api/start_research."""
|
||||
research_data = {
|
||||
"query": "Test from browser endpoint",
|
||||
"mode": "quick",
|
||||
"model_provider": "OLLAMA",
|
||||
"model": "llama2",
|
||||
"search_engine": "searxng",
|
||||
"max_results": 10,
|
||||
"time_period": "y",
|
||||
"iterations": 1,
|
||||
"questions_per_iteration": 3,
|
||||
"strategy": "source-based",
|
||||
"local_context": 2000,
|
||||
"web_context": 2000,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code in [200, 202]
|
||||
data = json.loads(response.data)
|
||||
|
||||
if response.status_code == 200:
|
||||
assert data.get("status") in ["success", "processing"]
|
||||
if "research_id" in data:
|
||||
assert isinstance(data["research_id"], (str, int))
|
||||
|
||||
def test_research_status_endpoint(self, authenticated_client):
|
||||
"""Test research status endpoint."""
|
||||
# First create a research
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX_ASSERTION).
|
||||
research_data = {
|
||||
"query": "Test research for status check",
|
||||
"mode": "quick",
|
||||
"model_provider": "OLLAMA",
|
||||
"model": "llama2",
|
||||
"search_engine": "wikipedia",
|
||||
"iterations": 1,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
research_id = data.get("research_id")
|
||||
|
||||
if research_id:
|
||||
# Check status
|
||||
status_response = authenticated_client.get(
|
||||
f"/research/api/status/{research_id}"
|
||||
)
|
||||
assert status_response.status_code in [200, 404]
|
||||
|
||||
if status_response.status_code == 200:
|
||||
status_data = json.loads(status_response.data)
|
||||
assert "status" in status_data
|
||||
|
||||
def test_endpoint_requires_authentication(self, client):
|
||||
"""Test that endpoint requires authentication."""
|
||||
research_data = {
|
||||
"query": "Test without auth",
|
||||
"mode": "quick",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Should either redirect to login or return 401
|
||||
assert response.status_code in [302, 401]
|
||||
|
||||
if response.status_code == 302:
|
||||
assert "/auth/login" in response.location
|
||||
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
Tests for Collection CRUD API endpoints.
|
||||
|
||||
Tests the following endpoints:
|
||||
- GET /library/api/collections
|
||||
- POST /library/api/collections
|
||||
- PUT /library/api/collections/<id>
|
||||
- DELETE /library/api/collections/<id>
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
# Import helpers from conftest
|
||||
from .conftest import delete_test_collection
|
||||
|
||||
|
||||
class TestGetCollections:
|
||||
"""Tests for GET /library/api/collections endpoint."""
|
||||
|
||||
def test_get_collections_unauthenticated(self, client):
|
||||
"""Unauthenticated request is rejected."""
|
||||
response = client.get("/library/api/collections")
|
||||
# Should redirect to login or return 401
|
||||
assert response.status_code in [302, 401]
|
||||
|
||||
def test_get_collections_authenticated_empty(self, authenticated_client):
|
||||
"""Returns collections list (may include default Library)."""
|
||||
response = authenticated_client.get("/library/api/collections")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
assert "collections" in data
|
||||
assert isinstance(data["collections"], list)
|
||||
|
||||
def test_get_collections_with_data(
|
||||
self, authenticated_client, create_collection_helper
|
||||
):
|
||||
"""Returns created collections."""
|
||||
# Create a test collection
|
||||
created = create_collection_helper(
|
||||
name="API Test Collection",
|
||||
description="Testing GET endpoint",
|
||||
)
|
||||
assert created is not None
|
||||
|
||||
response = authenticated_client.get("/library/api/collections")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
|
||||
# Find our created collection
|
||||
collection_names = [c["name"] for c in data["collections"]]
|
||||
assert "API Test Collection" in collection_names
|
||||
|
||||
def test_get_collections_includes_metadata(
|
||||
self, authenticated_client, create_collection_helper
|
||||
):
|
||||
"""Collections include required metadata fields."""
|
||||
created = create_collection_helper()
|
||||
assert created is not None
|
||||
|
||||
response = authenticated_client.get("/library/api/collections")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert len(data["collections"]) > 0
|
||||
|
||||
# Check metadata fields
|
||||
coll = data["collections"][0]
|
||||
assert "id" in coll
|
||||
assert "name" in coll
|
||||
assert "description" in coll
|
||||
assert "created_at" in coll
|
||||
assert "collection_type" in coll
|
||||
assert "document_count" in coll
|
||||
|
||||
|
||||
class TestCreateCollection:
|
||||
"""Tests for POST /library/api/collections endpoint."""
|
||||
|
||||
def test_create_collection_unauthenticated(self, client):
|
||||
"""Unauthenticated request is rejected."""
|
||||
response = client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": "Test", "description": "Test"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [302, 401]
|
||||
|
||||
def test_create_collection_success(self, authenticated_client):
|
||||
"""Successfully creates a new collection."""
|
||||
unique_name = f"Test Collection {uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={
|
||||
"name": unique_name,
|
||||
"description": "A test collection",
|
||||
"type": "user_uploads",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
assert data["collection"]["name"] == unique_name
|
||||
assert data["collection"]["description"] == "A test collection"
|
||||
assert "id" in data["collection"]
|
||||
|
||||
# Cleanup
|
||||
delete_test_collection(authenticated_client, data["collection"]["id"])
|
||||
|
||||
def test_create_collection_defaults_private(self, authenticated_client):
|
||||
"""A new collection with no is_public flag defaults to private."""
|
||||
name = f"Priv {uuid.uuid4().hex[:8]}"
|
||||
resp = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": name},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
coll = resp.get_json()["collection"]
|
||||
assert coll["is_public"] is False
|
||||
delete_test_collection(authenticated_client, coll["id"])
|
||||
|
||||
def test_create_and_toggle_collection_is_public(self, authenticated_client):
|
||||
"""is_public can be set at create and flipped via PUT, and is
|
||||
reflected in the list API."""
|
||||
name = f"Pub {uuid.uuid4().hex[:8]}"
|
||||
resp = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": name, "is_public": True},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
coll = resp.get_json()["collection"]
|
||||
cid = coll["id"]
|
||||
assert coll["is_public"] is True
|
||||
|
||||
# List reflects it
|
||||
listing = authenticated_client.get(
|
||||
"/library/api/collections"
|
||||
).get_json()["collections"]
|
||||
match = next(c for c in listing if c["id"] == cid)
|
||||
assert match["is_public"] is True
|
||||
|
||||
# The collection-details load path (documents endpoint) also exposes
|
||||
# is_public so the details-page toggle can reflect current state.
|
||||
docs = authenticated_client.get(
|
||||
f"/library/api/collections/{cid}/documents"
|
||||
)
|
||||
assert docs.status_code == 200
|
||||
assert docs.get_json()["collection"]["is_public"] is True
|
||||
|
||||
# Flip back to private via PUT
|
||||
put = authenticated_client.put(
|
||||
f"/library/api/collections/{cid}",
|
||||
json={"is_public": False},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.get_json()["collection"]["is_public"] is False
|
||||
|
||||
delete_test_collection(authenticated_client, cid)
|
||||
|
||||
def test_create_collection_empty_name(self, authenticated_client):
|
||||
"""Rejects collection with empty name."""
|
||||
response = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": "", "description": "No name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"required" in data["error"].lower()
|
||||
or "name" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_create_collection_whitespace_name(self, authenticated_client):
|
||||
"""Rejects collection with whitespace-only name."""
|
||||
response = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": " ", "description": "Whitespace name"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_create_collection_duplicate_name(self, authenticated_client):
|
||||
"""Rejects collection with duplicate name."""
|
||||
unique_name = f"Duplicate Test {uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create first collection
|
||||
response1 = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": unique_name, "description": "First"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
collection_id = response1.get_json()["collection"]["id"]
|
||||
|
||||
try:
|
||||
# Try to create duplicate
|
||||
response2 = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": unique_name, "description": "Duplicate"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response2.status_code == 400
|
||||
|
||||
data = response2.get_json()
|
||||
assert data["success"] is False
|
||||
assert "exists" in data["error"].lower()
|
||||
finally:
|
||||
# Cleanup
|
||||
delete_test_collection(authenticated_client, collection_id)
|
||||
|
||||
def test_create_collection_without_description(self, authenticated_client):
|
||||
"""Creates collection without description."""
|
||||
unique_name = f"No Desc Collection {uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": unique_name},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
assert data["collection"]["name"] == unique_name
|
||||
|
||||
# Cleanup
|
||||
delete_test_collection(authenticated_client, data["collection"]["id"])
|
||||
|
||||
|
||||
class TestUpdateCollection:
|
||||
"""Tests for PUT /library/api/collections/<id> endpoint."""
|
||||
|
||||
def test_update_collection_unauthenticated(self, client):
|
||||
"""Unauthenticated request is rejected."""
|
||||
response = client.put(
|
||||
"/library/api/collections/some-id",
|
||||
json={"name": "Updated"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [302, 401]
|
||||
|
||||
def test_update_collection_name(
|
||||
self, authenticated_client, create_collection_helper
|
||||
):
|
||||
"""Successfully updates collection name."""
|
||||
created = create_collection_helper(name="Original Name")
|
||||
assert created is not None
|
||||
|
||||
new_name = f"Updated Name {uuid.uuid4().hex[:8]}"
|
||||
response = authenticated_client.put(
|
||||
f"/library/api/collections/{created['id']}",
|
||||
json={"name": new_name},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
assert data["collection"]["name"] == new_name
|
||||
|
||||
def test_update_collection_description(
|
||||
self, authenticated_client, create_collection_helper
|
||||
):
|
||||
"""Successfully updates collection description."""
|
||||
created = create_collection_helper(description="Original description")
|
||||
assert created is not None
|
||||
|
||||
response = authenticated_client.put(
|
||||
f"/library/api/collections/{created['id']}",
|
||||
json={"description": "Updated description"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
assert data["collection"]["description"] == "Updated description"
|
||||
|
||||
def test_update_collection_not_found(self, authenticated_client):
|
||||
"""Returns 404 for non-existent collection."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = authenticated_client.put(
|
||||
f"/library/api/collections/{fake_id}",
|
||||
json={"name": "Updated"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is False
|
||||
assert "not found" in data["error"].lower()
|
||||
|
||||
def test_update_collection_duplicate_name(
|
||||
self, authenticated_client, create_collection_helper
|
||||
):
|
||||
"""Rejects update with duplicate name."""
|
||||
# Create two collections
|
||||
coll1 = create_collection_helper(
|
||||
name=f"Collection A {uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
coll2 = create_collection_helper(
|
||||
name=f"Collection B {uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
assert coll1 is not None and coll2 is not None
|
||||
|
||||
# Try to rename coll2 to coll1's name
|
||||
response = authenticated_client.put(
|
||||
f"/library/api/collections/{coll2['id']}",
|
||||
json={"name": coll1["name"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is False
|
||||
assert "exists" in data["error"].lower()
|
||||
|
||||
|
||||
class TestDeleteCollection:
|
||||
"""Tests for DELETE /library/api/collections/<id> endpoint."""
|
||||
|
||||
def test_delete_collection_unauthenticated(self, client):
|
||||
"""Unauthenticated request is rejected."""
|
||||
response = client.delete("/library/api/collections/some-id")
|
||||
assert response.status_code in [302, 401]
|
||||
|
||||
def test_delete_collection_success(self, authenticated_client):
|
||||
"""Successfully deletes a collection."""
|
||||
# Create a collection to delete
|
||||
unique_name = f"To Delete {uuid.uuid4().hex[:8]}"
|
||||
create_response = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": unique_name, "description": "Will be deleted"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
collection_id = create_response.get_json()["collection"]["id"]
|
||||
|
||||
# Delete it
|
||||
response = authenticated_client.delete(
|
||||
f"/library/api/collections/{collection_id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is True
|
||||
|
||||
# Verify it's gone
|
||||
list_response = authenticated_client.get("/library/api/collections")
|
||||
collections = list_response.get_json()["collections"]
|
||||
collection_ids = [c["id"] for c in collections]
|
||||
assert collection_id not in collection_ids
|
||||
|
||||
def test_delete_collection_not_found(self, authenticated_client):
|
||||
"""Returns 404 for non-existent collection."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = authenticated_client.delete(
|
||||
f"/library/api/collections/{fake_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
data = response.get_json()
|
||||
assert data["success"] is False
|
||||
assert "not found" in data["error"].lower()
|
||||
|
||||
|
||||
class TestCollectionIntegration:
|
||||
"""Integration tests for collection workflow."""
|
||||
|
||||
def test_create_read_update_delete_flow(self, authenticated_client):
|
||||
"""Full CRUD workflow."""
|
||||
unique_name = f"CRUD Test {uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create
|
||||
create_response = authenticated_client.post(
|
||||
"/library/api/collections",
|
||||
json={"name": unique_name, "description": "Initial"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
collection_id = create_response.get_json()["collection"]["id"]
|
||||
|
||||
try:
|
||||
# Read
|
||||
list_response = authenticated_client.get("/library/api/collections")
|
||||
assert list_response.status_code == 200
|
||||
collections = list_response.get_json()["collections"]
|
||||
our_coll = next(
|
||||
(c for c in collections if c["id"] == collection_id), None
|
||||
)
|
||||
assert our_coll is not None
|
||||
assert our_coll["name"] == unique_name
|
||||
|
||||
# Update
|
||||
updated_name = f"Updated {uuid.uuid4().hex[:8]}"
|
||||
update_response = authenticated_client.put(
|
||||
f"/library/api/collections/{collection_id}",
|
||||
json={"name": updated_name, "description": "Updated desc"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
|
||||
# Verify update
|
||||
list_response2 = authenticated_client.get(
|
||||
"/library/api/collections"
|
||||
)
|
||||
collections2 = list_response2.get_json()["collections"]
|
||||
our_coll2 = next(
|
||||
(c for c in collections2 if c["id"] == collection_id), None
|
||||
)
|
||||
assert our_coll2["name"] == updated_name
|
||||
assert our_coll2["description"] == "Updated desc"
|
||||
|
||||
finally:
|
||||
# Delete
|
||||
delete_response = authenticated_client.delete(
|
||||
f"/library/api/collections/{collection_id}"
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Comprehensive API test suite with proper authentication handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestComprehensiveAPIs:
|
||||
"""Comprehensive API test suite using Flask test client."""
|
||||
|
||||
def test_auth_apis(self, authenticated_client):
|
||||
"""Test authentication APIs."""
|
||||
logger.info("Testing Authentication APIs")
|
||||
|
||||
# Check auth status
|
||||
response = authenticated_client.get("/auth/check")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["authenticated"] is True
|
||||
|
||||
# Test integrity check
|
||||
response = authenticated_client.get("/auth/integrity-check")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "integrity" in data
|
||||
|
||||
logger.info("✅ Auth APIs tested")
|
||||
|
||||
def test_settings_apis(self, authenticated_client):
|
||||
"""Test settings APIs."""
|
||||
logger.info("Testing Settings APIs")
|
||||
|
||||
# Get all settings
|
||||
response = authenticated_client.get("/settings/api")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "success"
|
||||
assert "settings" in data
|
||||
|
||||
# Get categories
|
||||
response = authenticated_client.get("/settings/api/categories")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "categories" in data
|
||||
|
||||
# Get types
|
||||
response = authenticated_client.get("/settings/api/types")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "types" in data
|
||||
|
||||
# Get available models
|
||||
print("\n[DEBUG] Testing /settings/api/available-models")
|
||||
response = authenticated_client.get("/settings/api/available-models")
|
||||
print(f"[DEBUG] Response status: {response.status_code}")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
print(f"[DEBUG] Response keys: {list(data.keys())}")
|
||||
# The endpoint returns provider_options and providers, not models
|
||||
assert "provider_options" in data
|
||||
assert "providers" in data
|
||||
|
||||
# Get available search engines
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/available-search-engines"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "engines" in data or "engine_options" in data
|
||||
|
||||
# Test setting CRUD (use an allow-listed prefix so the
|
||||
# namespace gate permits creation of this throwaway key).
|
||||
test_key = "llm.test_setting"
|
||||
test_value = {"value": "test_value_123"}
|
||||
|
||||
# Create/Update setting
|
||||
response = authenticated_client.put(
|
||||
f"/settings/api/{test_key}",
|
||||
json=test_value,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
# Get the setting
|
||||
response = authenticated_client.get(f"/settings/api/{test_key}")
|
||||
# Setting might have been created or might not exist
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
# Delete the setting
|
||||
response = authenticated_client.delete(f"/settings/api/{test_key}")
|
||||
assert response.status_code in [200, 204, 404]
|
||||
|
||||
logger.info("✅ Settings APIs tested")
|
||||
|
||||
def test_research_apis(self, authenticated_client):
|
||||
"""Test research APIs."""
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
logger.info("Testing Research APIs")
|
||||
|
||||
# Get research history (endpoint is /api/history not /research/api/history)
|
||||
response = authenticated_client.get("/api/history")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "history" in data or "items" in data
|
||||
|
||||
# Start a test research
|
||||
research_data = {
|
||||
"query": "Test research from comprehensive API test",
|
||||
"mode": "quick",
|
||||
"model_provider": "OLLAMA",
|
||||
"model": "llama2",
|
||||
"search_engine": "searxng",
|
||||
"max_results": 5,
|
||||
"iterations": 1,
|
||||
"questions_per_iteration": 2,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
assert "research_id" in data
|
||||
research_id = data["research_id"]
|
||||
|
||||
# Give it a moment to start
|
||||
time.sleep(0.5) # allow: unmarked-sleep
|
||||
|
||||
# Check status
|
||||
response = authenticated_client.get(
|
||||
f"/api/research/{research_id}/status"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Terminate research
|
||||
response = authenticated_client.post(
|
||||
f"/api/terminate/{research_id}"
|
||||
)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
logger.info("✅ Research APIs tested")
|
||||
|
||||
def test_metrics_apis(self, authenticated_client):
|
||||
"""Test metrics APIs."""
|
||||
logger.info("Testing Metrics APIs")
|
||||
|
||||
# Get metrics summary
|
||||
response = authenticated_client.get("/metrics/api/metrics")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'metrics' not 'summary'
|
||||
assert "metrics" in data
|
||||
|
||||
# Get enhanced metrics
|
||||
response = authenticated_client.get("/metrics/api/metrics/enhanced")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get pricing info
|
||||
response = authenticated_client.get("/metrics/api/pricing")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'pricing' not 'models'
|
||||
assert "pricing" in data
|
||||
|
||||
# Get cost analytics
|
||||
response = authenticated_client.get("/metrics/api/cost-analytics")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test cost calculation
|
||||
cost_data = {
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 500,
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
"/metrics/api/cost-calculation",
|
||||
json=cost_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
logger.info("✅ Metrics APIs tested")
|
||||
|
||||
def test_benchmark_apis(self, authenticated_client):
|
||||
"""Test benchmark APIs."""
|
||||
logger.info("Testing Benchmark APIs")
|
||||
|
||||
# Get benchmark history
|
||||
response = authenticated_client.get("/benchmark/api/history")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'runs' not 'history'
|
||||
assert "runs" in data
|
||||
|
||||
# Get saved configs
|
||||
response = authenticated_client.get("/benchmark/api/configs")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "configs" in data
|
||||
|
||||
# Check running benchmark
|
||||
response = authenticated_client.get("/benchmark/api/running")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint might return either is_running or success key
|
||||
assert "is_running" in data or "success" in data
|
||||
|
||||
# Validate config
|
||||
test_config = {
|
||||
"name": "Test Config",
|
||||
"queries": ["test query"],
|
||||
"models": ["gpt-3.5-turbo"],
|
||||
"search_engines": ["searxng"],
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
"/benchmark/api/validate-config",
|
||||
json=test_config,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
logger.info("✅ Benchmark APIs tested")
|
||||
|
||||
def test_history_apis(self, authenticated_client):
|
||||
"""Test history APIs."""
|
||||
logger.info("Testing History APIs")
|
||||
|
||||
response = authenticated_client.get("/history/api")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'items' not 'history'
|
||||
assert "items" in data
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
logger.info("✅ History APIs tested")
|
||||
|
||||
def test_config_apis(self, authenticated_client):
|
||||
"""Test configuration APIs."""
|
||||
logger.info("Testing Config APIs")
|
||||
|
||||
# Get current config (correct path is /research/api/settings/current-config)
|
||||
response = authenticated_client.get(
|
||||
"/research/api/settings/current-config"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The response has a 'config' key containing the actual config
|
||||
assert "config" in data or ("llm" in data and "search" in data)
|
||||
if "config" in data:
|
||||
config = data["config"]
|
||||
assert "provider" in config or "llm" in config
|
||||
|
||||
logger.info("✅ Config APIs tested")
|
||||
|
||||
def test_health_check_apis(self, authenticated_client):
|
||||
"""Test health check APIs."""
|
||||
logger.info("Testing Health Check APIs")
|
||||
|
||||
# Check Ollama status (correct path is /research/api/check/ollama_status)
|
||||
response = authenticated_client.get("/research/api/check/ollama_status")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'running' not 'available'
|
||||
assert "running" in data or "available" in data
|
||||
|
||||
# Check Ollama model (might return 400 if no model specified)
|
||||
response = authenticated_client.get("/research/api/check/ollama_model")
|
||||
assert response.status_code in [200, 400]
|
||||
|
||||
logger.info("✅ Health Check APIs tested")
|
||||
|
||||
def test_api_v1_endpoints(self, authenticated_client):
|
||||
"""Test API v1 endpoints."""
|
||||
logger.info("Testing API v1 endpoints")
|
||||
|
||||
# Health check
|
||||
response = authenticated_client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "ok"
|
||||
|
||||
# API documentation
|
||||
response = authenticated_client.get("/api/v1/")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["api_version"] == "v1"
|
||||
assert "endpoints" in data
|
||||
|
||||
logger.info("✅ API v1 endpoints tested")
|
||||
|
||||
def test_rate_limiting(self, authenticated_client):
|
||||
"""Test rate limiting functionality."""
|
||||
logger.info("Testing Rate Limiting")
|
||||
|
||||
# Get rate limiting status
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/rate-limiting/status"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get current rate limits
|
||||
response = authenticated_client.get(
|
||||
"/metrics/api/rate-limiting/current"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test cleanup
|
||||
response = authenticated_client.post(
|
||||
"/settings/api/rate-limiting/cleanup"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
logger.info("✅ Rate limiting tested")
|
||||
|
||||
def test_data_location_api(self, authenticated_client):
|
||||
"""Test data location API."""
|
||||
logger.info("Testing Data Location API")
|
||||
|
||||
response = authenticated_client.get("/settings/api/data-location")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'data_directory' not 'data_dir'
|
||||
assert "data_directory" in data
|
||||
assert "is_custom" in data
|
||||
|
||||
logger.info("✅ Data location API tested")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Test CSRF with header."""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class TestCSRFHeader:
|
||||
"""Test CSRF protection with headers."""
|
||||
|
||||
def test_csrf_with_header(self, client, app):
|
||||
"""Test CSRF protection using headers."""
|
||||
# Since we disabled CSRF for testing, this test verifies the login flow
|
||||
# In production, CSRF would be required
|
||||
|
||||
# Generate unique username to avoid conflicts
|
||||
test_username = f"testuser_csrf_{int(time.time() * 1000)}"
|
||||
|
||||
# Get login page
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 200
|
||||
|
||||
# In test mode, CSRF is disabled, so we can login without token
|
||||
login_data = {
|
||||
"username": test_username,
|
||||
"password": "TestPass123",
|
||||
}
|
||||
|
||||
# First register the user
|
||||
register_response = client.post(
|
||||
"/auth/register",
|
||||
data={
|
||||
"username": test_username,
|
||||
"password": "TestPass123",
|
||||
"confirm_password": "TestPass123",
|
||||
"acknowledge": "true",
|
||||
},
|
||||
)
|
||||
assert register_response.status_code in [200, 302]
|
||||
|
||||
# Now test login
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data=login_data,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code in [200, 302]
|
||||
|
||||
if response.status_code == 302:
|
||||
# Login successful, redirecting
|
||||
assert "/" in response.location or "/research" in response.location
|
||||
|
||||
def test_api_csrf_exemption(self, authenticated_client):
|
||||
"""Test that API routes are exempt from CSRF."""
|
||||
# API routes should work without CSRF token
|
||||
response = authenticated_client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Research API should also work
|
||||
response = authenticated_client.get("/research/api/history")
|
||||
assert response.status_code in [200, 404] # 404 if no history yet
|
||||
|
||||
def test_csrf_blueprint_exemptions_configured(self, app):
|
||||
"""Test that only api_v1 blueprint is CSRF-exempt.
|
||||
|
||||
Flask-WTF 1.2.2 requires Blueprint objects (not strings) in
|
||||
_exempt_blueprints. Only api_v1 should be exempt — the browser-facing
|
||||
blueprints (api, benchmark, research) should require CSRF tokens since
|
||||
the frontend already sends them.
|
||||
"""
|
||||
csrf = app.extensions.get("csrf")
|
||||
assert csrf is not None, "CSRFProtect extension not initialized"
|
||||
|
||||
exempt_names = {bp.name for bp in csrf._exempt_blueprints}
|
||||
|
||||
# api_v1 should be exempt (programmatic REST API)
|
||||
assert "api_v1" in exempt_names, (
|
||||
"Blueprint 'api_v1' missing from _exempt_blueprints. "
|
||||
"Ensure csrf.exempt() receives the Blueprint object, not a string."
|
||||
)
|
||||
|
||||
# Browser-facing blueprints should NOT be exempt
|
||||
for should_not_be_exempt in ("api", "benchmark", "research"):
|
||||
assert should_not_be_exempt not in exempt_names, (
|
||||
f"Blueprint '{should_not_be_exempt}' should not be in "
|
||||
f"_exempt_blueprints — it is browser-facing and the frontend "
|
||||
f"already sends CSRF tokens."
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Test with completely fresh session."""
|
||||
|
||||
import pytest
|
||||
import re
|
||||
import time
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestFreshSession:
|
||||
"""Test authentication with fresh session."""
|
||||
|
||||
def test_fresh_session_login(self, client):
|
||||
"""Test login with completely fresh session."""
|
||||
# Get login page
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /auth/login -> {response.status_code}")
|
||||
|
||||
# Extract CSRF token
|
||||
csrf_match = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
csrf_token = csrf_match.group(1) if csrf_match else None
|
||||
logger.info(f"CSRF token: {csrf_token}")
|
||||
|
||||
# Create unique test username to avoid conflicts
|
||||
test_username = f"testuser_fresh_{int(time.time() * 1000)}"
|
||||
test_password = "TestPass123"
|
||||
|
||||
# Register user first (in case it doesn't exist)
|
||||
register_data = {
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"confirm_password": test_password,
|
||||
"acknowledge": "true",
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
client.post("/auth/register", data=register_data)
|
||||
|
||||
# Get fresh login page for new CSRF token
|
||||
response = client.get("/auth/login")
|
||||
csrf_match = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
csrf_token = csrf_match.group(1) if csrf_match else None
|
||||
|
||||
# Login
|
||||
login_data = {
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
logger.info("Posting login data...")
|
||||
response = client.post(
|
||||
"/auth/login", data=login_data, follow_redirects=False
|
||||
)
|
||||
|
||||
logger.info(f"POST /auth/login -> {response.status_code}")
|
||||
|
||||
if response.status_code == 302:
|
||||
logger.info("✅ Login successful! Got redirect")
|
||||
|
||||
# Follow redirect
|
||||
location = response.headers.get("Location", "/")
|
||||
response = client.get(location, follow_redirects=False)
|
||||
logger.info(f"GET {location} -> {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("✅ Successfully accessed home page!")
|
||||
elif response.status_code == 302:
|
||||
logger.error(
|
||||
f"Got redirected again to: {response.headers.get('Location')}"
|
||||
)
|
||||
|
||||
# Test auth check
|
||||
response = client.get("/auth/check")
|
||||
logger.info(f"GET /auth/check -> {response.status_code}")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert data.get("authenticated") is True
|
||||
logger.info(f"✅ Authenticated as: {data.get('username')}")
|
||||
|
||||
else:
|
||||
pytest.fail(
|
||||
f"Login failed with status code: {response.status_code}"
|
||||
)
|
||||
|
||||
def test_fresh_session_api_access(self, client):
|
||||
"""Test API access with fresh session."""
|
||||
# Try to access API without login
|
||||
response = client.get("/settings/api")
|
||||
assert response.status_code in [
|
||||
401,
|
||||
302,
|
||||
] # Should be unauthorized or redirect
|
||||
|
||||
# Register and login
|
||||
response = client.get("/auth/login")
|
||||
csrf_match = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
csrf_token = csrf_match.group(1) if csrf_match else None
|
||||
|
||||
# Create unique test username to avoid conflicts
|
||||
test_username = f"testuser_api_{int(time.time() * 1000)}"
|
||||
test_password = "TestPass123"
|
||||
|
||||
register_data = {
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"confirm_password": test_password,
|
||||
"acknowledge": "true",
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
client.post("/auth/register", data=register_data)
|
||||
|
||||
# Get fresh login page
|
||||
response = client.get("/auth/login")
|
||||
csrf_match = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
csrf_token = csrf_match.group(1) if csrf_match else None
|
||||
|
||||
login_data = {
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/auth/login", data=login_data, follow_redirects=True
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Now try API access
|
||||
response = client.get("/settings/api")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data["status"] == "success"
|
||||
assert "settings" in data
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Tests for HTTP API example scripts.
|
||||
|
||||
Validates that the example scripts under examples/api_usage/http/ exist,
|
||||
are syntactically valid Python, and define the expected entry points.
|
||||
|
||||
The actual API functionality exercised by these examples is already covered
|
||||
by test_rest_api.py and test_endpoints_health.py via the Flask test client.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Root of the repository
|
||||
REPO_ROOT = Path(__file__).parent.parent.parent
|
||||
EXAMPLES_DIR = REPO_ROOT / "examples" / "api_usage" / "http"
|
||||
|
||||
# All example scripts that should be tested — single source of truth
|
||||
EXAMPLE_SCRIPTS = [
|
||||
"simple_working_example.py",
|
||||
"advanced/http_api_examples.py",
|
||||
"advanced/simple_http_example.py",
|
||||
]
|
||||
|
||||
|
||||
def _parse_example(relative_path: str) -> tuple[Path, str, ast.Module]:
|
||||
"""Read and parse an example file, failing if it doesn't exist.
|
||||
|
||||
Returns (path, source_text, ast_tree).
|
||||
"""
|
||||
path = EXAMPLES_DIR / relative_path
|
||||
if not path.is_file():
|
||||
pytest.fail(f"Example not found: {path}")
|
||||
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
return path, source, tree
|
||||
|
||||
|
||||
def _has_main_guard(tree: ast.Module) -> bool:
|
||||
"""Check if an AST contains an ``if __name__ == '__main__':`` guard."""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
test = node.test
|
||||
if not isinstance(test, ast.Compare):
|
||||
continue
|
||||
if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq):
|
||||
continue
|
||||
# Match both orderings:
|
||||
# __name__ == "__main__"
|
||||
# "__main__" == __name__
|
||||
left = test.left
|
||||
comparators = test.comparators
|
||||
if len(comparators) != 1:
|
||||
continue
|
||||
right = comparators[0]
|
||||
if (
|
||||
isinstance(left, ast.Name)
|
||||
and left.id == "__name__"
|
||||
and isinstance(right, ast.Constant)
|
||||
and right.value == "__main__"
|
||||
) or (
|
||||
isinstance(right, ast.Name)
|
||||
and right.id == "__name__"
|
||||
and isinstance(left, ast.Constant)
|
||||
and left.value == "__main__"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TestHttpExamplesExist:
|
||||
"""Verify that expected HTTP example files are present."""
|
||||
|
||||
def test_examples_directory_exists(self):
|
||||
assert EXAMPLES_DIR.is_dir(), (
|
||||
f"Examples directory not found: {EXAMPLES_DIR}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("relative_path", EXAMPLE_SCRIPTS)
|
||||
def test_example_file_exists(self, relative_path):
|
||||
path = EXAMPLES_DIR / relative_path
|
||||
assert path.is_file(), f"Missing example: {path}"
|
||||
|
||||
|
||||
class TestHttpExamplesSyntax:
|
||||
"""Verify that example scripts are syntactically valid Python."""
|
||||
|
||||
@pytest.mark.parametrize("relative_path", EXAMPLE_SCRIPTS)
|
||||
def test_example_parses(self, relative_path):
|
||||
"""Each example file must be valid Python (no syntax errors)."""
|
||||
_path, _source, tree = _parse_example(relative_path)
|
||||
assert tree is not None
|
||||
|
||||
@pytest.mark.parametrize("relative_path", EXAMPLE_SCRIPTS)
|
||||
def test_example_has_main_function(self, relative_path):
|
||||
"""Each example script should define a main() function."""
|
||||
_path, _source, tree = _parse_example(relative_path)
|
||||
|
||||
function_names = [
|
||||
node.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
]
|
||||
assert "main" in function_names, (
|
||||
f"{relative_path} should define a main() function"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("relative_path", EXAMPLE_SCRIPTS)
|
||||
def test_example_has_main_guard(self, relative_path):
|
||||
"""Each example script should have an if __name__ == '__main__' guard."""
|
||||
_path, _source, tree = _parse_example(relative_path)
|
||||
|
||||
assert _has_main_guard(tree), (
|
||||
f"{relative_path} should have an if __name__ == '__main__' guard"
|
||||
)
|
||||
|
||||
|
||||
class TestAdvancedExampleStructure:
|
||||
"""Verify the advanced example defines expected helper classes/functions."""
|
||||
|
||||
def test_ldr_client_class_defined(self):
|
||||
"""The advanced example should define an LDRClient class."""
|
||||
_path, _source, tree = _parse_example("advanced/http_api_examples.py")
|
||||
|
||||
class_names = [
|
||||
node.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ClassDef)
|
||||
]
|
||||
assert "LDRClient" in class_names, (
|
||||
"Advanced example should define LDRClient class"
|
||||
)
|
||||
|
||||
def test_example_functions_defined(self):
|
||||
"""The advanced example should define the documented example functions."""
|
||||
_path, _source, tree = _parse_example("advanced/http_api_examples.py")
|
||||
|
||||
function_names = {
|
||||
node.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
|
||||
expected_examples = [
|
||||
"example_quick_research",
|
||||
"example_settings_management",
|
||||
"example_research_history",
|
||||
]
|
||||
for fn in expected_examples:
|
||||
assert fn in function_names, (
|
||||
f"Advanced example should define {fn}()"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Test using manually created session from browser.
|
||||
|
||||
This test is skipped by default as it requires manual setup.
|
||||
To run it, you need to provide a session cookie from the browser.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires manual browser session setup")
|
||||
class TestManualBrowserAuth:
|
||||
"""Test authentication using browser session cookie."""
|
||||
|
||||
def test_manual_session_cookie(self, client):
|
||||
"""Test using manually provided session cookie.
|
||||
|
||||
To use this test:
|
||||
1. Login via browser at http://127.0.0.1:5000/auth/login
|
||||
2. Open Developer Tools (F12)
|
||||
3. Go to Application/Storage/Cookies
|
||||
4. Copy the 'session' cookie value
|
||||
5. Set MANUAL_SESSION_COOKIE environment variable
|
||||
6. Run with: pytest -m "not skip" tests/api_tests/test_manual_browser_auth.py
|
||||
"""
|
||||
# Get session cookie from environment
|
||||
session_cookie = os.environ.get("MANUAL_SESSION_COOKIE")
|
||||
if not session_cookie:
|
||||
pytest.skip("MANUAL_SESSION_COOKIE not set")
|
||||
|
||||
# Set the cookie in the test client
|
||||
client.set_cookie(
|
||||
domain="localhost", key="session", value=session_cookie
|
||||
)
|
||||
|
||||
logger.info("Testing with manual session cookie...")
|
||||
|
||||
# Test auth check
|
||||
response = client.get("/auth/check")
|
||||
logger.info(f"GET /auth/check -> {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.get_json()
|
||||
logger.info(f"Auth data: {data}")
|
||||
|
||||
if data.get("authenticated"):
|
||||
logger.info(f"✅ Authenticated as: {data.get('username')}")
|
||||
|
||||
# Test API endpoints
|
||||
response = client.get("/settings/api")
|
||||
logger.info(f"GET /settings/api -> {response.status_code}")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get("/settings/api/available-search-engines")
|
||||
logger.info(
|
||||
f"GET /settings/api/available-search-engines -> {response.status_code}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
if "engine_options" in data:
|
||||
logger.info(
|
||||
f"✅ Found {len(data['engine_options'])} search engines"
|
||||
)
|
||||
assert len(data["engine_options"]) > 0
|
||||
else:
|
||||
pytest.fail("Not authenticated - session cookie may be invalid")
|
||||
else:
|
||||
pytest.fail(f"Auth check failed with status {response.status_code}")
|
||||
|
||||
def test_manual_session_api_operations(self, client):
|
||||
"""Test API operations with manual session."""
|
||||
# This test is also skipped by default
|
||||
session_cookie = os.environ.get("MANUAL_SESSION_COOKIE")
|
||||
if not session_cookie:
|
||||
pytest.skip("MANUAL_SESSION_COOKIE not set")
|
||||
|
||||
client.set_cookie(
|
||||
domain="localhost", key="session", value=session_cookie
|
||||
)
|
||||
|
||||
# Test various API operations
|
||||
endpoints = [
|
||||
"/history/api",
|
||||
"/metrics/api/cost-analytics",
|
||||
"/settings/api/available-models",
|
||||
]
|
||||
|
||||
for endpoint in endpoints:
|
||||
response = client.get(endpoint)
|
||||
logger.info(f"GET {endpoint} -> {response.status_code}")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data is not None
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Test metrics API endpoints specifically.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestMetricsAPI:
|
||||
"""Test metrics API endpoints."""
|
||||
|
||||
def test_metrics_summary(self, authenticated_client):
|
||||
"""Test metrics summary endpoint."""
|
||||
logger.info("Testing metrics summary...")
|
||||
|
||||
response = authenticated_client.get("/metrics/api/metrics")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'metrics' not 'summary'
|
||||
assert "metrics" in data
|
||||
logger.info("✅ Metrics summary passed")
|
||||
|
||||
def test_enhanced_metrics(self, authenticated_client):
|
||||
"""Test enhanced metrics endpoint."""
|
||||
logger.info("Testing enhanced metrics...")
|
||||
|
||||
response = authenticated_client.get("/metrics/api/metrics/enhanced")
|
||||
assert response.status_code == 200
|
||||
logger.info("✅ Enhanced metrics passed")
|
||||
|
||||
def test_pricing_info(self, authenticated_client):
|
||||
"""Test pricing information endpoint."""
|
||||
logger.info("Testing pricing info...")
|
||||
|
||||
response = authenticated_client.get("/metrics/api/pricing")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
# The endpoint returns 'pricing' not 'models'
|
||||
assert "pricing" in data
|
||||
logger.info("✅ Pricing info passed")
|
||||
|
||||
def test_cost_analytics(self, authenticated_client):
|
||||
"""Test cost analytics endpoint."""
|
||||
logger.info("Testing cost analytics...")
|
||||
|
||||
response = authenticated_client.get("/metrics/api/cost-analytics")
|
||||
assert response.status_code == 200
|
||||
logger.info("✅ Cost analytics passed")
|
||||
|
||||
def test_cost_calculation(self, authenticated_client):
|
||||
"""Test cost calculation endpoint."""
|
||||
logger.info("Testing cost calculation...")
|
||||
|
||||
cost_data = {
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 500,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/metrics/api/cost-calculation",
|
||||
json=cost_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert "cost" in data or "total_cost" in data
|
||||
logger.info("✅ Cost calculation passed")
|
||||
|
||||
def test_star_reviews(self, authenticated_client):
|
||||
"""Test star reviews endpoint."""
|
||||
logger.info("Testing star reviews...")
|
||||
|
||||
response = authenticated_client.get("/metrics/api/star-reviews")
|
||||
assert response.status_code == 200
|
||||
logger.info("✅ Star reviews passed")
|
||||
|
||||
def test_rate_limiting_metrics(self, authenticated_client):
|
||||
"""Test rate limiting metrics endpoint."""
|
||||
logger.info("Testing rate limiting metrics...")
|
||||
|
||||
response = authenticated_client.get("/metrics/api/rate-limiting")
|
||||
assert response.status_code == 200
|
||||
logger.info("✅ Rate limiting metrics passed")
|
||||
|
||||
def test_current_rate_limits(self, authenticated_client):
|
||||
"""Test current rate limits endpoint."""
|
||||
logger.info("Testing current rate limits...")
|
||||
|
||||
response = authenticated_client.get(
|
||||
"/metrics/api/rate-limiting/current"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
logger.info("✅ Current rate limits passed")
|
||||
|
||||
def test_model_specific_pricing(self, authenticated_client):
|
||||
"""Test model-specific pricing endpoint."""
|
||||
logger.info("Testing model-specific pricing...")
|
||||
|
||||
# Test a specific model
|
||||
response = authenticated_client.get(
|
||||
"/metrics/api/pricing/gpt-3.5-turbo"
|
||||
)
|
||||
|
||||
# This endpoint might not exist for all models
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"Model pricing data: {data}")
|
||||
# Check for pricing fields based on actual response structure
|
||||
assert (
|
||||
"pricing" in data
|
||||
or "input" in data
|
||||
or "prompt" in data
|
||||
or "price" in data
|
||||
)
|
||||
logger.info("✅ Model-specific pricing passed")
|
||||
elif response.status_code == 404:
|
||||
logger.info(
|
||||
"⚠️ Model-specific pricing endpoint not found (expected)"
|
||||
)
|
||||
else:
|
||||
pytest.fail(f"Unexpected status code: {response.status_code}")
|
||||
|
||||
def test_metrics_error_handling(self, authenticated_client):
|
||||
"""Test metrics API error handling."""
|
||||
logger.info("Testing metrics error handling...")
|
||||
|
||||
# Test cost calculation with missing data
|
||||
response = authenticated_client.post(
|
||||
"/metrics/api/cost-calculation",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
# The endpoint might return 200 with error in response body
|
||||
assert response.status_code in [200, 400, 422]
|
||||
|
||||
# Test invalid model pricing
|
||||
response = authenticated_client.get(
|
||||
"/metrics/api/pricing/invalid-model-xyz"
|
||||
)
|
||||
# The endpoint returns 200 even for invalid models
|
||||
assert response.status_code in [200, 404, 400]
|
||||
|
||||
logger.info("✅ Metrics error handling passed")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Test with proper CSRF handling."""
|
||||
|
||||
import re
|
||||
import time
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestProperCSRF:
|
||||
"""Test authentication with proper CSRF token handling."""
|
||||
|
||||
def test_login_with_csrf(self, client):
|
||||
"""Test login with proper CSRF token."""
|
||||
# Step 1: Get the login page to establish session and get CSRF token
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
logger.info("Step 1: Getting login page...")
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /auth/login -> {response.status_code}")
|
||||
|
||||
# Extract CSRF token from the form
|
||||
csrf_match = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
assert csrf_match is not None, "Could not find CSRF token in login page"
|
||||
|
||||
csrf_token = csrf_match.group(1)
|
||||
logger.info(f"CSRF token extracted: {csrf_token[:20]}...")
|
||||
|
||||
# Create unique test username to avoid conflicts
|
||||
test_username = f"testuser_csrf_{int(time.time() * 1000)}"
|
||||
test_password = "TestPass123"
|
||||
|
||||
# Register user first
|
||||
register_data = {
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"confirm_password": test_password,
|
||||
"acknowledge": "true",
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
register_response = client.post(
|
||||
"/auth/register", data=register_data, follow_redirects=False
|
||||
)
|
||||
logger.info(
|
||||
f"Register response status: {register_response.status_code}"
|
||||
)
|
||||
|
||||
# After registration, check if we were redirected and handle accordingly
|
||||
if register_response.status_code == 302:
|
||||
# We might be auto-logged in after registration, so go directly to auth check
|
||||
response = client.get("/auth/check")
|
||||
if response.status_code == 200:
|
||||
data = response.get_json()
|
||||
if data.get("authenticated") is True:
|
||||
logger.info(
|
||||
"✅ User auto-authenticated after registration!"
|
||||
)
|
||||
logger.info(f"Auth check: {data}")
|
||||
return
|
||||
|
||||
# Get fresh CSRF token for login
|
||||
response = client.get("/auth/login")
|
||||
logger.info(
|
||||
f"Login page status after registration: {response.status_code}"
|
||||
)
|
||||
|
||||
# If we're redirected, we might already be logged in
|
||||
if response.status_code == 302:
|
||||
response = client.get("/auth/check")
|
||||
if response.status_code == 200:
|
||||
data = response.get_json()
|
||||
if data.get("authenticated") is True:
|
||||
logger.info("✅ User already authenticated!")
|
||||
logger.info(f"Auth check: {data}")
|
||||
return
|
||||
|
||||
csrf_match = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
assert csrf_match is not None, (
|
||||
f"Could not find CSRF token in login page after registration. Status: {response.status_code}, Content preview: {response.data.decode()[:200]}"
|
||||
)
|
||||
csrf_token = csrf_match.group(1)
|
||||
|
||||
# Step 2: Submit login form with CSRF token
|
||||
login_data = {
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"csrf_token": csrf_token,
|
||||
}
|
||||
|
||||
logger.info("Step 2: Submitting login form...")
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data=login_data,
|
||||
headers={
|
||||
"Referer": "http://localhost/auth/login"
|
||||
}, # Some CSRF checks require referer
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
logger.info(f"POST /auth/login -> {response.status_code}")
|
||||
|
||||
assert response.status_code == 302, "Login should redirect on success"
|
||||
logger.info("✅ Login successful!")
|
||||
|
||||
# Step 3: Follow redirect and test authenticated access
|
||||
location = response.headers.get("Location", "/")
|
||||
response = client.get(location)
|
||||
logger.info(f"GET {location} -> {response.status_code}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test auth check
|
||||
response = client.get("/auth/check")
|
||||
logger.info(f"GET /auth/check -> {response.status_code}")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
logger.info(f"Auth check: {data}")
|
||||
assert data.get("authenticated") is True
|
||||
logger.info("✅ Authentication test passed!")
|
||||
|
||||
def test_login_without_csrf(self, app):
|
||||
"""Test that login fails without CSRF token."""
|
||||
# Enable CSRF for this test
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
client = app.test_client()
|
||||
|
||||
# Try to login without CSRF token
|
||||
login_data = {
|
||||
"username": "testuser",
|
||||
"password": "TestPass123",
|
||||
# No csrf_token
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/auth/login", data=login_data, follow_redirects=False
|
||||
)
|
||||
|
||||
# Should fail due to missing CSRF token
|
||||
assert response.status_code != 302 # Should not redirect (success)
|
||||
logger.info("✅ Login correctly rejected without CSRF token")
|
||||
|
||||
def test_login_with_invalid_csrf(self, app):
|
||||
"""Test that login fails with invalid CSRF token."""
|
||||
# Enable CSRF for this test
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
client = app.test_client()
|
||||
|
||||
# Get login page
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Use an invalid CSRF token
|
||||
login_data = {
|
||||
"username": "testuser",
|
||||
"password": "TestPass123",
|
||||
"csrf_token": "invalid_token_12345",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/auth/login", data=login_data, follow_redirects=False
|
||||
)
|
||||
|
||||
# Should fail due to invalid CSRF token
|
||||
assert response.status_code != 302 # Should not redirect (success)
|
||||
logger.info("✅ Login correctly rejected with invalid CSRF token")
|
||||
|
||||
def test_csrf_token_rotation(self, client):
|
||||
"""Test that CSRF tokens are rotated between requests."""
|
||||
# Get first CSRF token
|
||||
response = client.get("/auth/login")
|
||||
csrf_match1 = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
csrf_token1 = csrf_match1.group(1) if csrf_match1 else None
|
||||
|
||||
# Get second CSRF token
|
||||
response = client.get("/auth/login")
|
||||
csrf_match2 = re.search(
|
||||
r'name="csrf_token" value="([^"]+)"', response.data.decode()
|
||||
)
|
||||
csrf_token2 = csrf_match2.group(1) if csrf_match2 else None
|
||||
|
||||
# Tokens should be different (rotation) or at least valid
|
||||
assert csrf_token1 is not None
|
||||
assert csrf_token2 is not None
|
||||
logger.info("✅ CSRF tokens are properly generated")
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test research creation endpoint specifically
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
class TestResearchCreation:
|
||||
"""Test research creation functionality.
|
||||
|
||||
All tests in this class POST to /api/start_research with real
|
||||
Ollama / searxng / model dependencies (PUNCHLIST Tier 4: FLAKY_SLEEP).
|
||||
The class-level @pytest.mark.requires_llm gate makes them auto-
|
||||
skip in environments without a live LLM stack, so CI doesn't
|
||||
flake on the timing-sensitive sleep(0.5) and model-availability
|
||||
assertions. Manual runs can invoke them with -m requires_llm.
|
||||
"""
|
||||
|
||||
def test_research_creation_endpoint(self, authenticated_client):
|
||||
"""Test the research creation endpoint."""
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX_ASSERTION).
|
||||
print("\nTesting /api/start_research endpoint...")
|
||||
|
||||
research_data = {
|
||||
"query": "Test research query from Python",
|
||||
"mode": "quick",
|
||||
"model": "gpt-3.5-turbo",
|
||||
"search_engines": ["searxng"],
|
||||
"local_context": 2000,
|
||||
"web_context": 2000,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
print(f"Response status: {response.status_code}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
|
||||
if data.get("status") == "success":
|
||||
assert "research_id" in data
|
||||
print(f"\n✅ SUCCESS! Research ID: {data.get('research_id')}")
|
||||
return data["research_id"]
|
||||
# Alternative response format
|
||||
assert "research_id" in data
|
||||
print(f"\n✅ SUCCESS! Research ID: {data['research_id']}")
|
||||
return data["research_id"]
|
||||
|
||||
def test_research_creation_with_minimal_params(self, authenticated_client):
|
||||
"""Test research creation with minimal parameters."""
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
research_data = {
|
||||
"query": "Minimal test query",
|
||||
"mode": "quick",
|
||||
"model": "llama2",
|
||||
"model_provider": "OLLAMA",
|
||||
}
|
||||
|
||||
print(f"\n[DEBUG] Sending minimal research request: {research_data}")
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
print(f"[DEBUG] Response status: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
print(f"[DEBUG] Response data: {response.data.decode()[:500]}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "research_id" in data or (
|
||||
data.get("status") == "success" and "research_id" in data
|
||||
)
|
||||
|
||||
def test_research_creation_validation(self, authenticated_client):
|
||||
"""Test research creation validation."""
|
||||
# Missing query
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json={"mode": "quick"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data or "message" in data
|
||||
|
||||
def test_research_status_check(self, authenticated_client):
|
||||
"""Test checking research status."""
|
||||
# First create a research
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
research_data = {
|
||||
"query": "Status check test",
|
||||
"mode": "quick",
|
||||
"model": "llama2",
|
||||
"model_provider": "OLLAMA",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
|
||||
# Extract research_id
|
||||
if "research_id" in data:
|
||||
research_id = data["research_id"]
|
||||
else:
|
||||
research_id = data.get("data", {}).get("research_id")
|
||||
|
||||
assert research_id is not None
|
||||
|
||||
# Give it a moment to start
|
||||
time.sleep(0.5) # allow: unmarked-sleep
|
||||
|
||||
# Check status
|
||||
response = authenticated_client.get(
|
||||
f"/api/research/{research_id}/status"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
status_data = json.loads(response.data)
|
||||
assert "status" in status_data or "research_status" in status_data
|
||||
|
||||
def test_research_termination(self, authenticated_client):
|
||||
"""Test terminating a research."""
|
||||
# First create a research
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
research_data = {
|
||||
"query": "Termination test",
|
||||
"mode": "quick",
|
||||
"model": "llama2",
|
||||
"model_provider": "OLLAMA",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
research_id = data.get("research_id") or data.get("data", {}).get(
|
||||
"research_id"
|
||||
)
|
||||
assert research_id is not None
|
||||
|
||||
# Give it a moment to start
|
||||
time.sleep(0.5) # allow: unmarked-sleep
|
||||
|
||||
# Terminate it
|
||||
response = authenticated_client.post(f"/api/terminate/{research_id}")
|
||||
assert response.status_code in [200, 404] # 404 if already finished
|
||||
|
||||
def test_research_modes(self, authenticated_client):
|
||||
"""Test different research modes."""
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX_ASSERTION).
|
||||
modes = ["quick", "normal", "comprehensive"]
|
||||
|
||||
for mode in modes:
|
||||
research_data = {
|
||||
"query": f"Test {mode} mode",
|
||||
"mode": mode,
|
||||
"model": "llama2",
|
||||
"model_provider": "OLLAMA",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "research_id" in data or (
|
||||
data.get("status") == "success" and "research_id" in data
|
||||
)
|
||||
print(f"✅ {mode} mode research created successfully")
|
||||
|
||||
# Terminate to clean up
|
||||
research_id = data.get("research_id") or data.get("data", {}).get(
|
||||
"research_id"
|
||||
)
|
||||
if research_id:
|
||||
authenticated_client.post(f"/api/terminate/{research_id}")
|
||||
|
||||
def test_research_with_custom_model(self, authenticated_client):
|
||||
"""Test research with custom model settings."""
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
research_data = {
|
||||
"query": "Custom model test",
|
||||
"mode": "quick",
|
||||
"model_provider": "OLLAMA",
|
||||
"model": "mistral",
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 1000,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert "research_id" in data or (
|
||||
data.get("status") == "success" and "research_id" in data
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Test suite for REST API endpoints using minimal queries.
|
||||
Tests programmatic access functionality with fast, simple requests.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
# Test timeout in seconds
|
||||
TEST_TIMEOUT = 30
|
||||
|
||||
|
||||
class TestRestAPI:
|
||||
"""Test REST API endpoints with minimal queries."""
|
||||
|
||||
def test_health_check(self, client):
|
||||
"""Test the health check endpoint."""
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "ok"
|
||||
assert "timestamp" in data
|
||||
print("✅ Health check passed")
|
||||
|
||||
def test_api_documentation(self, authenticated_client):
|
||||
"""Test the API documentation endpoint (requires auth)."""
|
||||
response = authenticated_client.get("/api/v1/")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data["api_version"] == "v1"
|
||||
assert "endpoints" in data
|
||||
assert len(data["endpoints"]) >= 3 # Should have at least 3 endpoints
|
||||
print("✅ API documentation passed")
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
def test_quick_summary_minimal(self, authenticated_client):
|
||||
"""Test quick summary with minimal query."""
|
||||
payload = {
|
||||
"query": "Python",
|
||||
"search_tool": "wikipedia",
|
||||
"iterations": 1,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
|
||||
# Verify response structure
|
||||
assert "query" in data
|
||||
assert "summary" in data
|
||||
assert "findings" in data
|
||||
assert data["query"] == "Python"
|
||||
assert len(data["summary"]) > 10 # Should have actual content
|
||||
assert isinstance(data["findings"], list)
|
||||
|
||||
print(
|
||||
f"✅ Quick summary passed - got {len(data['summary'])} chars of summary"
|
||||
)
|
||||
|
||||
def test_quick_summary_validation(self, authenticated_client):
|
||||
"""Test quick summary endpoint validation."""
|
||||
# Test missing query
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
print("✅ Quick summary validation passed")
|
||||
|
||||
def test_analyze_documents_rejects_inline_documents(
|
||||
self, authenticated_client
|
||||
):
|
||||
"""``/api/v1/analyze_documents`` searches a *named local collection*;
|
||||
it does not accept inline ``documents``.
|
||||
|
||||
The endpoint validates request-body keys against the real
|
||||
``analyze_documents`` signature (``web/api.py``) and rejects unknown
|
||||
keys with a clear 400 *before* any LLM call, so this needs no real LLM.
|
||||
Previously this test posted an unsupported ``documents`` key and
|
||||
asserted a 200 with an ``analysis``/``processed_documents`` body that
|
||||
the endpoint never returns (the real shape is
|
||||
``{summary, documents, collection, document_count}``).
|
||||
"""
|
||||
payload = {
|
||||
"documents": ["Python is a programming language."],
|
||||
"query": "What is Python?",
|
||||
"collection_name": "test_collection",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# Unsupported "documents" key -> clear 400 (not an opaque 500).
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
# The rejected key is named *as the offender* (after the colon) — not
|
||||
# merely a substring of "analyze_documents" in the message prefix.
|
||||
offenders = data["error"].split(":", 1)[1]
|
||||
assert "documents" in offenders
|
||||
assert "documents" not in data["allowed_parameters"]
|
||||
assert "max_results" in data["allowed_parameters"]
|
||||
|
||||
print("✅ Analyze documents rejects inline documents")
|
||||
|
||||
def test_analyze_documents_validation(self, authenticated_client):
|
||||
"""Test analyze documents endpoint validation."""
|
||||
# Test missing collection_name
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json={"query": "test"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
# Test missing query
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json={"collection_name": "test"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
print("✅ Analyze documents validation passed")
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
def test_generate_report_minimal(self, authenticated_client):
|
||||
"""Test generate report with minimal input."""
|
||||
payload = {
|
||||
"query": "AI basics",
|
||||
"research_type": "quick",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/generate_report",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# This endpoint might not be fully implemented
|
||||
assert response.status_code in [200, 404, 500]
|
||||
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
assert "report" in data or "research_id" in data
|
||||
print("✅ Generate report passed")
|
||||
else:
|
||||
print("⚠️ Generate report endpoint not fully implemented")
|
||||
|
||||
def test_generate_report_validation(self, authenticated_client):
|
||||
"""Test generate report endpoint validation."""
|
||||
# Test with empty payload
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/generate_report",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
|
||||
print("✅ Generate report validation passed")
|
||||
|
||||
def test_error_handling(self, authenticated_client):
|
||||
"""Test API error handling."""
|
||||
# Test non-existent endpoint
|
||||
response = authenticated_client.get("/api/v1/nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
||||
# Test invalid JSON
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
data="invalid json",
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code in [400, 500]
|
||||
|
||||
print("✅ Error handling passed")
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Simple REST API tests with ultra-minimal queries and longer timeouts.
|
||||
Focus on basic functionality verification.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
# Extended timeout for research operations
|
||||
RESEARCH_TIMEOUT = 120 # 2 minutes
|
||||
|
||||
|
||||
class TestRestAPISimple:
|
||||
"""Simple REST API tests."""
|
||||
|
||||
def test_health_and_docs(self, authenticated_client):
|
||||
"""Test basic non-research endpoints."""
|
||||
print("🔍 Testing health and documentation endpoints...")
|
||||
|
||||
# Health check
|
||||
response = authenticated_client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "ok"
|
||||
print("✅ Health check passed")
|
||||
|
||||
# API documentation (requires auth)
|
||||
response = authenticated_client.get("/api/v1/")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["api_version"] == "v1"
|
||||
assert "endpoints" in data
|
||||
print("✅ API documentation passed")
|
||||
|
||||
def test_error_handling(self, authenticated_client):
|
||||
"""Test error handling for malformed requests."""
|
||||
print("🔍 Testing error handling...")
|
||||
|
||||
# Missing query parameter
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json={},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
print("✅ Error handling for missing query passed")
|
||||
|
||||
# Missing parameters for analyze_documents
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/analyze_documents",
|
||||
json={"query": "test"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "error" in data
|
||||
print("✅ Error handling for missing collection_name passed")
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
def test_quick_summary_ultra_minimal(self, authenticated_client):
|
||||
"""Test quick summary with the most minimal possible query."""
|
||||
print("🔍 Testing quick summary with ultra-minimal query...")
|
||||
|
||||
payload = {
|
||||
"query": "cat", # Single word, very common
|
||||
"search_tool": "wikipedia",
|
||||
"iterations": 1,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
print("Making request...")
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/quick_summary",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
|
||||
# Basic structure validation
|
||||
required_fields = ["query", "summary", "findings"]
|
||||
for field in required_fields:
|
||||
assert field in data, f"Missing field: {field}"
|
||||
|
||||
assert data["query"] == "cat"
|
||||
assert len(data["summary"]) > 0, "Summary should not be empty"
|
||||
assert isinstance(data["findings"], list), (
|
||||
"Findings should be a list"
|
||||
)
|
||||
|
||||
print(
|
||||
f"✅ Quick summary passed - got {len(data['summary'])} chars of summary"
|
||||
)
|
||||
print(f" Found {len(data['findings'])} findings")
|
||||
else:
|
||||
pytest.fail(
|
||||
f"Quick summary failed with status {response.status_code}"
|
||||
)
|
||||
print(f" Response: {response.data[:200]}")
|
||||
|
||||
@pytest.mark.requires_llm
|
||||
def test_generate_report_minimal(self, authenticated_client):
|
||||
"""Test generate report with minimal input."""
|
||||
print("🔍 Testing generate report with minimal input...")
|
||||
|
||||
payload = {
|
||||
"query": "Sun",
|
||||
"research_type": "quick",
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/api/v1/generate_report",
|
||||
json=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# This endpoint might not be fully implemented
|
||||
if response.status_code == 200:
|
||||
print("✅ Generate report passed")
|
||||
elif response.status_code in [404, 500]:
|
||||
print(
|
||||
f"⚠️ Generate report endpoint not fully implemented ({response.status_code})"
|
||||
)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"Generate report failed with unexpected status {response.status_code}"
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Focused test for the search engines API endpoint.
|
||||
|
||||
This test specifically debugs why the search engines dropdown is not loading.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestSearchEnginesAPI:
|
||||
"""Test the search engines API endpoint with detailed debugging."""
|
||||
|
||||
def test_search_engines_api(self, authenticated_client):
|
||||
"""Test the search engines API endpoint."""
|
||||
logger.info("Testing search engines API...")
|
||||
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/available-search-engines"
|
||||
)
|
||||
|
||||
logger.info(f"Response status: {response.status_code}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
|
||||
logger.info(f"Response data: {json.dumps(data, indent=2)}")
|
||||
|
||||
# Check the structure
|
||||
if "engines" in data:
|
||||
logger.info(f"Found {len(data['engines'])} engines")
|
||||
assert len(data["engines"]) > 0
|
||||
for engine_name, engine_data in data["engines"].items():
|
||||
logger.info(f" - {engine_name}: {engine_data}")
|
||||
elif "engine_options" in data:
|
||||
logger.info(f"Found {len(data['engine_options'])} engine options")
|
||||
assert len(data["engine_options"]) > 0
|
||||
for option in data["engine_options"]:
|
||||
logger.info(f" - {option}")
|
||||
else:
|
||||
pytest.fail(
|
||||
"Unexpected data structure - neither 'engines' nor 'engine_options' found"
|
||||
)
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Search engine display_name settings are not loaded by default in test environment"
|
||||
)
|
||||
def test_related_search_settings(self, authenticated_client):
|
||||
"""Check related search engine settings."""
|
||||
logger.info("Checking related settings...")
|
||||
|
||||
# Check if search engine settings exist
|
||||
endpoints = [
|
||||
"/settings/api/search.engine.web.searxng.display_name",
|
||||
"/settings/api/search.engine.web.duckduckgo.display_name",
|
||||
"/settings/api/search.engine.web.google.display_name",
|
||||
"/settings/api/search.engine.web.bing.display_name",
|
||||
]
|
||||
|
||||
found_settings = 0
|
||||
for endpoint in endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"✓ {endpoint}: {data}")
|
||||
found_settings += 1
|
||||
else:
|
||||
logger.warning(f"✗ {endpoint}: {response.status_code}")
|
||||
|
||||
# At least some search engines should be configured
|
||||
assert found_settings > 0, "No search engine settings found"
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Search settings are not loaded by default in test environment"
|
||||
)
|
||||
def test_all_search_settings(self, authenticated_client):
|
||||
"""Check all settings to see what's available."""
|
||||
logger.info("Checking all settings...")
|
||||
|
||||
response = authenticated_client.get("/settings/api")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "success"
|
||||
assert "settings" in data
|
||||
|
||||
# Find search-related settings
|
||||
search_settings = {
|
||||
k: v for k, v in data["settings"].items() if "search" in k
|
||||
}
|
||||
|
||||
logger.info(f"Found {len(search_settings)} search-related settings:")
|
||||
for key, value in search_settings.items():
|
||||
logger.info(f" {key}: {value}")
|
||||
|
||||
# Should have some search settings
|
||||
assert len(search_settings) > 0, "No search settings found"
|
||||
|
||||
def test_search_engine_configuration(self, authenticated_client):
|
||||
"""Test search engine configuration endpoints."""
|
||||
# Test getting search engine configuration
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/search.default_search_engine"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"Default search engine: {data}")
|
||||
assert "value" in data or "setting" in data
|
||||
else:
|
||||
logger.warning(
|
||||
f"No default search engine configured: {response.status_code}"
|
||||
)
|
||||
|
||||
# Test search engine specific settings
|
||||
response = authenticated_client.get("/settings/api/search.max_results")
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"Max search results: {data}")
|
||||
assert "value" in data or "setting" in data
|
||||
|
||||
def test_search_engines_dropdown_data(self, authenticated_client):
|
||||
"""Test the exact data structure needed for dropdown."""
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/available-search-engines"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
|
||||
# The frontend expects either:
|
||||
# 1. {"engines": {"name": {"display_name": "Name", "enabled": true}}}
|
||||
# 2. {"engine_options": ["engine1", "engine2"]}
|
||||
|
||||
if "engines" in data:
|
||||
# Validate engines structure
|
||||
for engine_name, engine_data in data["engines"].items():
|
||||
assert isinstance(engine_data, dict), (
|
||||
f"Engine {engine_name} data should be dict"
|
||||
)
|
||||
# Check for expected fields
|
||||
if "display_name" in engine_data:
|
||||
assert isinstance(engine_data["display_name"], str)
|
||||
if "enabled" in engine_data:
|
||||
assert isinstance(engine_data["enabled"], bool)
|
||||
elif "engine_options" in data:
|
||||
# Validate engine_options structure
|
||||
assert isinstance(data["engine_options"], list), (
|
||||
"engine_options should be list"
|
||||
)
|
||||
assert len(data["engine_options"]) > 0, (
|
||||
"engine_options should not be empty"
|
||||
)
|
||||
for option in data["engine_options"]:
|
||||
assert isinstance(option, (str, dict)), (
|
||||
"Each option should be string or dict"
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Test session persistence after login."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestSessionPersistence:
|
||||
"""Test session persistence functionality."""
|
||||
|
||||
def test_session_after_login(self, client):
|
||||
"""Test that session persists after login."""
|
||||
# Create unique test username to avoid conflicts
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REFACTOR_SPLIT).
|
||||
test_username = f"testuser_session_{int(time.time() * 1000)}"
|
||||
test_password = "TestPass123"
|
||||
|
||||
# Get login page
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /auth/login -> {response.status_code}")
|
||||
|
||||
# Register/Login
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
data={
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"confirm_password": test_password,
|
||||
"acknowledge": "true",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Login
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
logger.info(f"POST /auth/login -> {response.status_code}")
|
||||
|
||||
# Check if we're logged in by verifying successful redirect
|
||||
assert (
|
||||
response.status_code == 302
|
||||
) # Should redirect after successful login
|
||||
|
||||
# Access home page
|
||||
response = client.get("/", follow_redirects=False)
|
||||
logger.info(f"GET / -> {response.status_code}")
|
||||
|
||||
# Should not redirect to login
|
||||
if response.status_code == 302:
|
||||
location = response.headers.get("Location", "")
|
||||
assert "/auth/login" not in location, (
|
||||
"Session was lost! Redirected back to login"
|
||||
)
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
# Check if we're actually logged in
|
||||
assert (
|
||||
b"logout" in response.data.lower()
|
||||
or b"dashboard" in response.data.lower()
|
||||
)
|
||||
logger.info("✅ Successfully logged in and session preserved!")
|
||||
|
||||
def test_auth_check_persistence(self, authenticated_client):
|
||||
"""Test auth check endpoint with persistent session."""
|
||||
response = authenticated_client.get("/auth/check")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /auth/check -> {response.status_code}")
|
||||
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"Auth check data: {data}")
|
||||
assert data.get("authenticated") is True
|
||||
assert "username" in data
|
||||
logger.info(f"✅ Authenticated as: {data.get('username')}")
|
||||
|
||||
def test_api_access_persistence(self, authenticated_client):
|
||||
"""Test API access with persistent session."""
|
||||
response = authenticated_client.get("/settings/api")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /settings/api -> {response.status_code}")
|
||||
logger.info("✅ API access works!")
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data["status"] == "success"
|
||||
assert "settings" in data
|
||||
|
||||
def test_multiple_requests_session(self, authenticated_client):
|
||||
"""Test that session persists across multiple requests."""
|
||||
endpoints = [
|
||||
"/auth/check",
|
||||
"/settings/api",
|
||||
"/history/api",
|
||||
"/metrics/api/metrics",
|
||||
]
|
||||
|
||||
for endpoint in endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
assert response.status_code == 200
|
||||
logger.info(f"✅ {endpoint} -> {response.status_code}")
|
||||
|
||||
def test_session_after_page_navigation(self, authenticated_client):
|
||||
"""Test session persists through page navigation."""
|
||||
pages = ["/", "/history", "/settings", "/metrics/"]
|
||||
|
||||
for page in pages:
|
||||
response = authenticated_client.get(page)
|
||||
assert response.status_code == 200
|
||||
# Should not redirect to login
|
||||
assert b"login" not in response.request.url.encode()
|
||||
logger.info(f"✅ Successfully accessed {page}")
|
||||
|
||||
def test_session_integrity_check(self, authenticated_client):
|
||||
"""Test session integrity check endpoint."""
|
||||
response = authenticated_client.get("/auth/integrity-check")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert "integrity" in data
|
||||
assert "username" in data
|
||||
logger.info(
|
||||
f"✅ Session integrity verified for user: {data['username']}"
|
||||
)
|
||||
|
||||
def test_logout_clears_session(self, authenticated_client):
|
||||
"""Test that logout properly clears the session."""
|
||||
# First verify we're logged in
|
||||
response = authenticated_client.get("/auth/check")
|
||||
data = json.loads(response.data)
|
||||
assert data["authenticated"] is True
|
||||
|
||||
# Logout
|
||||
response = authenticated_client.post(
|
||||
"/auth/logout", follow_redirects=True
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Try to access protected endpoint
|
||||
response = authenticated_client.get("/settings/api")
|
||||
# Should either get 401 or redirect to login
|
||||
assert response.status_code in [401, 302]
|
||||
logger.info("✅ Session properly cleared after logout")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Simple authentication test."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestSimpleAuth:
|
||||
"""Simple authentication tests."""
|
||||
|
||||
def test_login_flow(self, client):
|
||||
"""Test the login flow."""
|
||||
# Create unique test username to avoid conflicts
|
||||
test_username = f"testuser_simple_{int(time.time() * 1000)}"
|
||||
test_password = "TestPass123"
|
||||
|
||||
# 1. Get login page
|
||||
response = client.get("/auth/login")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /auth/login -> {response.status_code}")
|
||||
|
||||
# 2. Register first (in case user doesn't exist)
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
data={
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
"confirm_password": test_password,
|
||||
"acknowledge": "true",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Either 302 (success) or error if already exists
|
||||
logger.info(f"POST /auth/register -> {response.status_code}")
|
||||
|
||||
# 3. Login
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={
|
||||
"username": test_username,
|
||||
"password": test_password,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
logger.info(
|
||||
f"POST /auth/login (with redirects) -> {response.status_code}"
|
||||
)
|
||||
logger.info(f"Final URL: {response.request.url}")
|
||||
|
||||
# Should redirect to home page after successful login
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_auth_check(self, authenticated_client):
|
||||
"""Test auth check endpoint."""
|
||||
response = authenticated_client.get("/auth/check")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET /auth/check -> {response.status_code}")
|
||||
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"Auth check data: {data}")
|
||||
assert data["authenticated"] is True
|
||||
|
||||
def test_access_home_page(self, authenticated_client):
|
||||
"""Test accessing home page when authenticated."""
|
||||
response = authenticated_client.get("/")
|
||||
assert response.status_code == 200
|
||||
logger.info(f"GET / -> {response.status_code}")
|
||||
|
||||
def test_api_access(self, authenticated_client):
|
||||
"""Test API access when authenticated."""
|
||||
response = authenticated_client.get(
|
||||
"/settings/api/available-search-engines"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
logger.info(
|
||||
f"GET /settings/api/available-search-engines -> {response.status_code}"
|
||||
)
|
||||
|
||||
data = json.loads(response.data)
|
||||
logger.info(f"Search engines response has keys: {list(data.keys())}")
|
||||
|
||||
if "engine_options" in data:
|
||||
logger.info(
|
||||
f"Found {len(data['engine_options'])} search engine options"
|
||||
)
|
||||
assert len(data["engine_options"]) > 0
|
||||
elif "engines" in data:
|
||||
logger.info(f"Found {len(data['engines'])} search engines")
|
||||
assert len(data["engines"]) > 0
|
||||
|
||||
def test_unauthenticated_api_access(self, client):
|
||||
"""Test API access without authentication."""
|
||||
# Should get 401 or redirect to login
|
||||
response = client.get("/settings/api/available-search-engines")
|
||||
assert response.status_code in [401, 302]
|
||||
logger.info(
|
||||
f"GET /settings/api/available-search-engines (unauth) -> {response.status_code}"
|
||||
)
|
||||
|
||||
def test_logout(self, authenticated_client):
|
||||
"""Test logout functionality."""
|
||||
# First verify we're authenticated
|
||||
response = authenticated_client.get("/auth/check")
|
||||
data = json.loads(response.data)
|
||||
assert data["authenticated"] is True
|
||||
|
||||
# Logout
|
||||
print("\n[DEBUG] Testing logout endpoint with POST method")
|
||||
response = authenticated_client.post(
|
||||
"/auth/logout", follow_redirects=True
|
||||
)
|
||||
print(f"[DEBUG] Logout response status: {response.status_code}")
|
||||
print(f"[DEBUG] Logout response headers: {dict(response.headers)}")
|
||||
if response.status_code != 200:
|
||||
print(
|
||||
f"[DEBUG] Logout response data: {response.data.decode()[:500]}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check we're no longer authenticated
|
||||
response = authenticated_client.get("/auth/check")
|
||||
assert response.status_code in [401, 200]
|
||||
if response.status_code == 200:
|
||||
data = json.loads(response.data)
|
||||
assert data["authenticated"] is False
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Test authentication without CSRF to verify CSRF protection."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TestWithoutCSRF:
|
||||
"""Test that authentication properly requires CSRF tokens."""
|
||||
|
||||
def test_login_without_csrf_protection(self, app):
|
||||
"""Test login behavior when CSRF protection is disabled."""
|
||||
# Temporarily disable CSRF for this test
|
||||
app.config["WTF_CSRF_ENABLED"] = False
|
||||
client = app.test_client()
|
||||
|
||||
# Register user
|
||||
register_data = {
|
||||
"username": "testuser_nocsrf",
|
||||
"password": "TestPass123",
|
||||
"confirm_password": "TestPass123",
|
||||
"acknowledge": "true",
|
||||
}
|
||||
client.post("/auth/register", data=register_data)
|
||||
|
||||
# Try login without CSRF token
|
||||
login_data = {
|
||||
"username": "testuser_nocsrf",
|
||||
"password": "TestPass123",
|
||||
}
|
||||
|
||||
# Direct POST without getting the login page first
|
||||
response = client.post(
|
||||
"/auth/login", data=login_data, follow_redirects=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"POST /auth/login (CSRF disabled) -> {response.status_code}"
|
||||
)
|
||||
|
||||
# With CSRF disabled, login should work
|
||||
assert response.status_code == 302
|
||||
logger.info("✅ Login worked with CSRF disabled!")
|
||||
|
||||
# Re-enable CSRF
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
|
||||
def test_login_requires_csrf_by_default(self, app):
|
||||
"""Test that login requires CSRF token when enabled."""
|
||||
# Create a new client with CSRF enabled
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
client = app.test_client()
|
||||
|
||||
# Try login without CSRF token (CSRF should be enabled)
|
||||
login_data = {"username": "testuser", "password": "TestPass123"}
|
||||
|
||||
# Direct POST without CSRF token
|
||||
response = client.post(
|
||||
"/auth/login", data=login_data, follow_redirects=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"POST /auth/login (no CSRF token) -> {response.status_code}"
|
||||
)
|
||||
|
||||
# Should fail because CSRF token is missing
|
||||
assert response.status_code != 302 # Should not redirect on success
|
||||
logger.info("✅ Login correctly requires CSRF token!")
|
||||
|
||||
def test_api_endpoints_without_csrf(self, authenticated_client):
|
||||
"""Test that API endpoints work without CSRF for authenticated users."""
|
||||
# API endpoints typically don't require CSRF for GET requests
|
||||
endpoints = [
|
||||
"/settings/api",
|
||||
"/history/api",
|
||||
"/metrics/api/cost-analytics",
|
||||
]
|
||||
|
||||
for endpoint in endpoints:
|
||||
response = authenticated_client.get(endpoint)
|
||||
assert response.status_code == 200
|
||||
logger.info(f"✅ GET {endpoint} works without CSRF token")
|
||||
|
||||
def test_post_api_without_csrf(self, authenticated_client):
|
||||
"""Test that POST API endpoints properly handle CSRF."""
|
||||
# Try to start research without CSRF token
|
||||
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: FIX_ASSERTION).
|
||||
research_data = {"query": "test query", "mode": "quick"}
|
||||
|
||||
# POST without CSRF should fail unless endpoint is exempt
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
json=research_data, # JSON requests typically bypass CSRF
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
# JSON API endpoints often bypass CSRF protection
|
||||
logger.info(
|
||||
f"POST /api/start_research (JSON) -> {response.status_code}"
|
||||
)
|
||||
|
||||
# Form POST without CSRF should fail
|
||||
response = authenticated_client.post(
|
||||
"/api/start_research",
|
||||
data=research_data, # Form data should require CSRF
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"POST /api/start_research (form data, no CSRF) -> {response.status_code}"
|
||||
)
|
||||
Reference in New Issue
Block a user