391 lines
13 KiB
Python
391 lines
13 KiB
Python
"""Tests for API endpoints."""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from web.api.app import create_app
|
|
from web.api.dependencies import init_concurrency_limiter
|
|
from web.config import SettingsCache, WebSettings
|
|
from web.exceptions import (
|
|
ProviderError,
|
|
RateLimitError,
|
|
WebConnectionError,
|
|
WebTimeoutError,
|
|
)
|
|
from web.fetch.client import FetchClient
|
|
from web.metasearch.client import SearXNGClient
|
|
from web.orchestrator import Orchestrator
|
|
from web.schemas.search import SearchResponse, SearchResult
|
|
from web.search.paid import PaidSearchClient
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_response() -> SearchResponse:
|
|
"""Create a mock search response."""
|
|
return SearchResponse(
|
|
request_id="test-id",
|
|
results=[
|
|
SearchResult(
|
|
query="test",
|
|
url="https://example.com",
|
|
title="Test",
|
|
snippet="Test snippet",
|
|
rank=1,
|
|
site="example.com",
|
|
published_at=None,
|
|
)
|
|
],
|
|
total_results=1,
|
|
execution_time_ms=100.0,
|
|
queries_processed=1,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client(test_settings: WebSettings) -> TestClient:
|
|
"""Create test client with mocked settings."""
|
|
SettingsCache.set(test_settings)
|
|
init_concurrency_limiter(test_settings.max_concurrent_requests)
|
|
|
|
app = create_app()
|
|
app.state.settings = test_settings
|
|
app.state.search_client = SearXNGClient(settings=test_settings)
|
|
app.state.search_client_free = SearXNGClient(settings=test_settings)
|
|
app.state.search_client_premium = PaidSearchClient(settings=test_settings)
|
|
app.state.fetch_client = FetchClient(settings=test_settings)
|
|
app.state.orchestrator_free = Orchestrator(settings=test_settings)
|
|
app.state.orchestrator_premium = Orchestrator(
|
|
settings=test_settings, llm_provider="openrouter"
|
|
)
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
class TestHealthEndpoints:
|
|
"""Tests for health check endpoints."""
|
|
|
|
def test_ready_endpoint(self, test_client: TestClient) -> None:
|
|
"""Test /ready endpoint."""
|
|
response = test_client.get("/ready")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ready": True}
|
|
|
|
def test_health_endpoint(self, test_client: TestClient) -> None:
|
|
"""Test /health endpoint."""
|
|
with patch.object(
|
|
SearXNGClient, "health_check", new_callable=AsyncMock
|
|
) as mock_health:
|
|
mock_health.return_value = True
|
|
|
|
response = test_client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
assert len(data["providers"]) == 1
|
|
assert data["providers"][0]["name"] == "searxng"
|
|
|
|
|
|
class TestSearchEndpoint:
|
|
"""Tests for /v1/search endpoint."""
|
|
|
|
def test_search_success(
|
|
self, test_client: TestClient, mock_response: SearchResponse
|
|
) -> None:
|
|
"""Test successful search."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.return_value = mock_response
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test query"], "max_results": 5},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_results"] == 1
|
|
assert len(data["results"]) == 1
|
|
assert data["results"][0]["url"] == "https://example.com"
|
|
|
|
def test_search_validation_empty_query(self, test_client: TestClient) -> None:
|
|
"""Test that empty query is rejected."""
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": [""], "max_results": 5},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_search_validation_too_many_queries(self, test_client: TestClient) -> None:
|
|
"""Test that too many queries are rejected."""
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": [f"query{i}" for i in range(15)], "max_results": 5},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_search_with_site_allowlist(
|
|
self, test_client: TestClient, mock_response: SearchResponse
|
|
) -> None:
|
|
"""Test search with site allowlist."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.return_value = mock_response
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={
|
|
"queries": ["test"],
|
|
"site_allowlist": ["example.com", "test.com"],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
def test_request_id_header(
|
|
self, test_client: TestClient, mock_response: SearchResponse
|
|
) -> None:
|
|
"""Test that X-Request-ID header is returned."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.return_value = mock_response
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
headers={"X-Request-ID": "custom-request-id"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert "X-Request-ID" in response.headers
|
|
assert response.headers["X-Request-ID"] == "custom-request-id"
|
|
|
|
|
|
class TestFetchEndpoint:
|
|
"""Tests for /v1/fetch endpoint."""
|
|
|
|
def test_fetch_rejects_private_urls(self, test_client: TestClient) -> None:
|
|
"""Test that private/localhost URLs are rejected (SSRF protection)."""
|
|
response = test_client.post(
|
|
"/v1/fetch",
|
|
json={"urls": ["http://localhost/secret"]},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_fetch_rejects_non_http_schemes(self, test_client: TestClient) -> None:
|
|
"""Test that non-HTTP(S) schemes are rejected."""
|
|
response = test_client.post(
|
|
"/v1/fetch",
|
|
json={"urls": ["ftp://example.com/file"]},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_fetch_validation_empty_urls(self, test_client: TestClient) -> None:
|
|
"""Test that empty URL list is rejected."""
|
|
response = test_client.post(
|
|
"/v1/fetch",
|
|
json={"urls": []},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
class TestInfoEndpoint:
|
|
"""Tests for /v1/info endpoint."""
|
|
|
|
def test_info_returns_resource(self, test_client: TestClient) -> None:
|
|
"""Test that /v1/info returns resource information."""
|
|
response = test_client.get("/v1/info")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "resource" in data
|
|
assert data["resource"]["slug"] == "web-factcheck"
|
|
|
|
def test_info_returns_functions(self, test_client: TestClient) -> None:
|
|
"""Test that /v1/info returns function definitions."""
|
|
response = test_client.get("/v1/info")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "functions" in data
|
|
assert len(data["functions"]) >= 2
|
|
slugs = [f["slug"] for f in data["functions"]]
|
|
assert "web-gather-evidence" in slugs
|
|
assert "web-search" in slugs
|
|
assert "web-fetch" in slugs
|
|
|
|
|
|
class TestSearchErrorResponses:
|
|
"""Tests for error response codes on /v1/search."""
|
|
|
|
def test_search_returns_429_on_rate_limit(self, test_client: TestClient) -> None:
|
|
"""Test that RateLimitError returns 429."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.side_effect = RateLimitError(
|
|
"Rate limit exceeded", retry_after=5.0
|
|
)
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
)
|
|
|
|
assert response.status_code == 429
|
|
assert "Retry-After" in response.headers
|
|
|
|
def test_search_returns_502_on_connection_error(
|
|
self, test_client: TestClient
|
|
) -> None:
|
|
"""Test that WebConnectionError returns 502."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.side_effect = WebConnectionError("searxng", "refused")
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
data = response.json()
|
|
assert data["detail"]["error"] == "connection_error"
|
|
|
|
def test_search_returns_502_on_provider_error(
|
|
self, test_client: TestClient
|
|
) -> None:
|
|
"""Test that ProviderError returns 502."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.side_effect = ProviderError("searxng", "Server error")
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
data = response.json()
|
|
assert data["detail"]["error"] == "provider_error"
|
|
|
|
def test_search_returns_504_on_timeout(self, test_client: TestClient) -> None:
|
|
"""Test that WebTimeoutError returns 504."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.side_effect = WebTimeoutError("Timeout", timeout=30.0)
|
|
|
|
response = test_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
)
|
|
|
|
assert response.status_code == 504
|
|
data = response.json()
|
|
assert data["detail"]["error"] == "timeout"
|
|
|
|
|
|
class TestAuthFlow:
|
|
"""Tests for Bearer token authentication."""
|
|
|
|
@pytest.fixture
|
|
def auth_settings(self) -> WebSettings:
|
|
"""Create settings with authentication enabled."""
|
|
return WebSettings(
|
|
searxng_base_url="http://localhost:55100",
|
|
llm_base_url="http://localhost:14011",
|
|
external_url="http://localhost:51100",
|
|
api_tokens="valid-token-123,valid-token-456",
|
|
)
|
|
|
|
@pytest.fixture
|
|
def auth_client(self, auth_settings: WebSettings) -> TestClient:
|
|
"""Create test client with auth enabled."""
|
|
SettingsCache.set(auth_settings)
|
|
init_concurrency_limiter(auth_settings.max_concurrent_requests)
|
|
|
|
app = create_app()
|
|
app.state.settings = auth_settings
|
|
app.state.search_client = SearXNGClient(settings=auth_settings)
|
|
app.state.search_client_free = SearXNGClient(settings=auth_settings)
|
|
app.state.search_client_premium = PaidSearchClient(settings=auth_settings)
|
|
app.state.fetch_client = FetchClient(settings=auth_settings)
|
|
app.state.orchestrator_free = Orchestrator(settings=auth_settings)
|
|
app.state.orchestrator_premium = Orchestrator(
|
|
settings=auth_settings, llm_provider="openrouter"
|
|
)
|
|
|
|
return TestClient(app)
|
|
|
|
def test_auth_required_returns_401_without_token(
|
|
self, auth_client: TestClient
|
|
) -> None:
|
|
"""Test that missing auth header returns 401."""
|
|
response = auth_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
def test_auth_required_returns_401_with_invalid_token(
|
|
self, auth_client: TestClient
|
|
) -> None:
|
|
"""Test that invalid token returns 401."""
|
|
response = auth_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
headers={"Authorization": "Bearer wrong-token"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
def test_auth_succeeds_with_valid_token(self, auth_client: TestClient) -> None:
|
|
"""Test that valid token allows access."""
|
|
with patch.object(
|
|
SearXNGClient, "search", new_callable=AsyncMock
|
|
) as mock_search:
|
|
mock_search.return_value = SearchResponse(
|
|
request_id="test",
|
|
results=[],
|
|
total_results=0,
|
|
execution_time_ms=10.0,
|
|
queries_processed=1,
|
|
)
|
|
|
|
response = auth_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
headers={"Authorization": "Bearer valid-token-123"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
def test_auth_invalid_format_returns_401(self, auth_client: TestClient) -> None:
|
|
"""Test that non-Bearer auth format returns 401."""
|
|
response = auth_client.post(
|
|
"/v1/search",
|
|
json={"queries": ["test"]},
|
|
headers={"Authorization": "Basic dXNlcjpwYXNz"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
def test_health_endpoints_skip_auth(self, auth_client: TestClient) -> None:
|
|
"""Test that health endpoints don't require auth."""
|
|
response = auth_client.get("/ready")
|
|
assert response.status_code == 200
|