Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

2205 lines
92 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Comprehensive tests for local_deep_research.web.api REST API endpoints.
Covers all blueprint routes, the api_access_control decorator (auth, API-disabled,
rate-limiting), and the _serialize_results helper.
"""
import sys
import time
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from local_deep_research.web.api import api_blueprint
from local_deep_research.security.rate_limiter import limiter
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _auth_session(client, username="testuser"):
"""Inject an authenticated session."""
with client.session_transaction() as sess:
sess["username"] = username
@contextmanager
def _mock_access_control(*, api_enabled=True, rate_limit=60):
"""Context manager that patches get_user_db_session + get_settings_manager
so that the api_access_control decorator lets requests through (or blocks
them if api_enabled=False).
The rate_limit parameter controls the per-user API rate limit cached on
``g._api_rate_limit`` by the ``api_access_control`` decorator. Setting it
here ensures that the real ``_get_user_api_rate_limit`` function reads the
correct value from the cache, which keeps Flask-Limiter's enforcement
consistent even under xdist test-parallelism where the global ``limiter``
singleton may carry stale state from other test modules.
"""
with patch("local_deep_research.web.api.get_user_db_session") as mock_ctx:
mock_session = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
with patch(
"local_deep_research.web.api.get_settings_manager"
) as mock_sm:
mock_manager = MagicMock()
mock_manager.get_setting.side_effect = lambda key, default: {
"app.enable_api": api_enabled,
"app.api_rate_limit": rate_limit,
}.get(key, default)
# Tracer setting so contract tests can verify the user's
# snapshot reaches the underlying research function.
# `_ldr_test_tracer` is reserved for tests; no production
# code reads this key.
mock_manager.get_settings_snapshot.return_value = {
"_ldr_test_tracer": "tracer-value"
}
mock_sm.return_value = mock_manager
yield mock_ctx, mock_sm
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_limiter(app):
"""Reset Flask-Limiter storage before and after each test.
Resetting *before* the test prevents stale counters left by other test
modules that share the global ``limiter`` singleton (common under xdist).
"""
with app.app_context():
try:
limiter.reset()
except Exception:
pass
yield
with app.app_context():
try:
limiter.reset()
except Exception:
pass
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["SECRET_KEY"] = "test-secret"
flask_app.config["TESTING"] = True
flask_app.config["WTF_CSRF_ENABLED"] = False
flask_app.config["RATELIMIT_ENABLED"] = True
flask_app.config["RATELIMIT_STRATEGY"] = "moving-window"
flask_app.register_blueprint(api_blueprint)
limiter.init_app(flask_app)
return flask_app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def authed_client(client):
_auth_session(client)
return client
# ===================================================================
# /api/v1/health
# ===================================================================
class TestHealthCheck:
"""GET /api/v1/health no auth required."""
def test_returns_ok(self, client):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
assert data["message"] == "API is running"
def test_timestamp_is_recent(self, client):
resp = client.get("/api/v1/health")
ts = resp.get_json()["timestamp"]
assert abs(ts - time.time()) < 5
def test_unauthenticated_access_allowed(self, client):
"""Health check must be accessible without a session."""
resp = client.get("/api/v1/health")
assert resp.status_code == 200
def test_no_resources_when_unauthenticated(self, client):
"""Unauthenticated requests do not get resource diagnostics."""
resp = client.get("/api/v1/health")
data = resp.get_json()
assert data["status"] == "ok"
assert "resources" not in data
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_resources_structure(self, _mock_user, client):
"""Authenticated response contains resources dict with expected keys."""
resp = client.get("/api/v1/health")
data = resp.get_json()
assert "resources" in data
res = data["resources"]
expected_keys = {
"fd_count",
"fd_soft_limit",
"fd_hard_limit",
"fd_usage_percent",
"thread_count",
}
assert set(res.keys()) == expected_keys
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_thread_count_positive(self, _mock_user, client):
"""thread_count is always >= 1 (main thread)."""
resp = client.get("/api/v1/health")
tc = resp.get_json()["resources"]["thread_count"]
assert isinstance(tc, int)
assert tc >= 1
@pytest.mark.skipif(
sys.platform != "linux", reason="/proc/self/fd only on Linux"
)
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_fd_count_on_linux(self, _mock_user, client):
"""On Linux, fd_count is a non-negative integer."""
resp = client.get("/api/v1/health")
fd = resp.get_json()["resources"]["fd_count"]
assert isinstance(fd, int)
assert fd >= 0
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_no_resource_module(self, _mock_user, client):
"""When resource module is unavailable, FD limits are None."""
with patch("local_deep_research.web.api._resource_mod", None):
resp = client.get("/api/v1/health")
data = resp.get_json()
assert data["status"] == "ok"
assert data["resources"]["fd_soft_limit"] is None
assert data["resources"]["fd_hard_limit"] is None
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_no_proc_fs(self, _mock_user, client):
"""When /proc/self/fd is unavailable, fd_count and percent are None."""
with patch("os.listdir", side_effect=OSError("no /proc")):
resp = client.get("/api/v1/health")
res = resp.get_json()["resources"]
assert res["fd_count"] is None
assert res["fd_usage_percent"] is None
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_warning_status_high_fd(self, _mock_user, client):
"""Status becomes 'warning' when FD usage exceeds 70%."""
fake_fds = [str(i) for i in range(80)]
with (
patch("os.listdir", return_value=fake_fds),
patch("local_deep_research.web.api._resource_mod") as mock_res,
):
mock_res.RLIM_INFINITY = -1
mock_res.RLIMIT_NOFILE = 7
mock_res.getrlimit.return_value = (100, 100)
resp = client.get("/api/v1/health")
data = resp.get_json()
assert data["status"] == "warning"
assert "High FD usage" in data["message"]
assert data["resources"]["fd_usage_percent"] == 80.0
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_ok_status_normal_fd(self, _mock_user, client):
"""Status stays 'ok' when FD usage is low."""
fake_fds = [str(i) for i in range(10)]
with (
patch("os.listdir", return_value=fake_fds),
patch("local_deep_research.web.api._resource_mod") as mock_res,
):
mock_res.RLIM_INFINITY = -1
mock_res.RLIMIT_NOFILE = 7
mock_res.getrlimit.return_value = (1000, 1000)
resp = client.get("/api/v1/health")
data = resp.get_json()
assert data["status"] == "ok"
assert data["resources"]["fd_usage_percent"] == 1.0
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_getrlimit_oserror_does_not_500(self, _mock_user, client):
"""A getrlimit OSError must not break the health endpoint."""
with patch("local_deep_research.web.api._resource_mod") as mock_res:
mock_res.RLIM_INFINITY = -1
mock_res.RLIMIT_NOFILE = 7
mock_res.getrlimit.side_effect = OSError("getrlimit failed")
resp = client.get("/api/v1/health")
assert resp.status_code == 200
res = resp.get_json()["resources"]
assert res["fd_soft_limit"] is None
assert res["fd_hard_limit"] is None
assert res["fd_usage_percent"] is None
@patch(
"local_deep_research.web.api.get_current_username",
return_value="testuser",
)
def test_rlim_infinity_becomes_none(self, _mock_user, client):
"""RLIM_INFINITY limits are reported as None."""
with patch("local_deep_research.web.api._resource_mod") as mock_res:
mock_res.RLIM_INFINITY = -1
mock_res.RLIMIT_NOFILE = 7
mock_res.getrlimit.return_value = (-1, -1)
resp = client.get("/api/v1/health")
res = resp.get_json()["resources"]
assert res["fd_soft_limit"] is None
assert res["fd_hard_limit"] is None
assert res["fd_usage_percent"] is None
# ===================================================================
# /api/v1/ (api_documentation)
# ===================================================================
class TestApiDocumentation:
"""GET /api/v1/ requires auth + api_access_control."""
def test_unauthenticated_returns_401(self, client):
resp = client.get("/api/v1/")
assert resp.status_code == 401
assert "authentication" in resp.get_json()["error"].lower()
def test_returns_api_docs(self, authed_client):
with _mock_access_control():
resp = authed_client.get("/api/v1/")
assert resp.status_code == 200
data = resp.get_json()
assert data["api_version"] == "v1"
assert data["description"] == "REST API for Local Deep Research"
assert len(data["endpoints"]) == 3
def test_lists_all_endpoint_paths(self, authed_client):
with _mock_access_control():
resp = authed_client.get("/api/v1/")
paths = [ep["path"] for ep in resp.get_json()["endpoints"]]
assert "/api/v1/quick_summary" in paths
assert "/api/v1/generate_report" in paths
assert "/api/v1/analyze_documents" in paths
def test_endpoint_entries_have_method_and_description(self, authed_client):
with _mock_access_control():
resp = authed_client.get("/api/v1/")
for ep in resp.get_json()["endpoints"]:
assert "method" in ep
assert "description" in ep
assert "parameters" in ep
def test_all_endpoints_document_allow_default_settings(self, authed_client):
"""allow_default_settings is accepted by every research endpoint
(fail-closed opt-out) — the docs endpoint must advertise it."""
with _mock_access_control():
resp = authed_client.get("/api/v1/")
for ep in resp.get_json()["endpoints"]:
assert "allow_default_settings" in ep["parameters"], (
f"{ep['path']} does not document allow_default_settings"
)
# ===================================================================
# api_access_control decorator
# ===================================================================
class TestApiAccessControl:
"""Tests exercising the api_access_control decorator paths."""
def test_no_session_returns_401(self, client):
resp = client.get("/api/v1/")
assert resp.status_code == 401
def test_api_disabled_returns_403(self, authed_client):
with _mock_access_control(api_enabled=False):
resp = authed_client.get("/api/v1/")
assert resp.status_code == 403
assert "disabled" in resp.get_json()["error"].lower()
def test_rate_limit_exceeded_returns_429(self, authed_client):
with _mock_access_control(rate_limit=2):
# First two should pass
resp1 = authed_client.get("/api/v1/")
assert resp1.status_code == 200
resp2 = authed_client.get("/api/v1/")
assert resp2.status_code == 200
# Third should be rate-limited
resp3 = authed_client.get("/api/v1/")
assert resp3.status_code == 429
def test_rate_limit_429_returns_json_with_custom_handler(self, app):
"""429 response has JSON body with 'error' and 'message' keys
when the custom handler from app_factory is registered."""
from flask import jsonify
# Register the same custom 429 handler as app_factory.py
@app.errorhandler(429)
def ratelimit_handler(e):
return (
jsonify(
error="Too many requests",
message="Too many attempts. Please try again later.",
),
429,
)
with app.test_client() as client:
_auth_session(client)
with _mock_access_control(rate_limit=1):
# First request passes
resp1 = client.get("/api/v1/")
assert resp1.status_code == 200
# Second hits the limit
resp2 = client.get("/api/v1/")
assert resp2.status_code == 429
body = resp2.get_json()
assert body is not None, "429 response should be JSON"
assert body["error"] == "Too many requests"
assert "message" in body
def test_rate_limit_headers_present_on_success(self, authed_client):
"""Successful responses include X-RateLimit headers."""
with _mock_access_control(rate_limit=10):
resp = authed_client.get("/api/v1/")
assert resp.status_code == 200
# Flask-Limiter adds these headers when headers_enabled=True
assert "X-RateLimit-Limit" in resp.headers
assert "X-RateLimit-Remaining" in resp.headers
assert "X-RateLimit-Reset" in resp.headers
def test_rate_limit_remaining_header_decrements(self, authed_client):
"""X-RateLimit-Remaining decrements with each request."""
with _mock_access_control(rate_limit=5):
resp1 = authed_client.get("/api/v1/")
assert resp1.status_code == 200
remaining1 = int(resp1.headers["X-RateLimit-Remaining"])
resp2 = authed_client.get("/api/v1/")
assert resp2.status_code == 200
remaining2 = int(resp2.headers["X-RateLimit-Remaining"])
assert remaining2 == remaining1 - 1
def test_different_users_have_independent_buckets(self, app):
"""User A hitting their limit does not affect User B."""
with _mock_access_control(rate_limit=2):
# User A exhausts their limit
client_a = app.test_client()
_auth_session(client_a, username="alice")
assert client_a.get("/api/v1/").status_code == 200
assert client_a.get("/api/v1/").status_code == 200
assert client_a.get("/api/v1/").status_code == 429
# User B is unaffected
client_b = app.test_client()
_auth_session(client_b, username="bob")
assert client_b.get("/api/v1/").status_code == 200
assert client_b.get("/api/v1/").status_code == 200
def test_rate_limit_zero_means_no_limiting(self, authed_client):
"""rate_limit=0 (falsy) should exempt from rate limiting."""
with _mock_access_control(rate_limit=0):
for _ in range(5):
resp = authed_client.get("/api/v1/")
assert resp.status_code == 200
def test_g_current_user_fallback(self, app):
"""When g.current_user is set, it should be used instead of session."""
with app.test_request_context():
from flask import g
g.current_user = "guser"
with _mock_access_control():
with app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "guser"
resp = c.get("/api/v1/")
assert resp.status_code == 200
def test_db_session_none_still_allows(self, authed_client):
"""When get_user_db_session returns None, api_enabled stays True."""
with patch(
"local_deep_research.web.api.get_user_db_session"
) as mock_ctx:
mock_ctx.return_value.__enter__ = MagicMock(return_value=None)
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
resp = authed_client.get("/api/v1/")
assert resp.status_code == 200
# ===================================================================
# /api/v1/quick_summary
# ===================================================================
class TestQuickSummary:
"""POST /api/v1/quick_summary"""
def test_unauthenticated_returns_401(self, client):
resp = client.post("/api/v1/quick_summary", json={"query": "hi"})
assert resp.status_code == 401
def test_no_json_body_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary", content_type="application/json"
)
assert resp.status_code == 400
def test_missing_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary", json={"search_tool": "searxng"}
)
assert resp.status_code == 400
def test_non_string_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": 42}
)
assert resp.status_code == 400
assert "string" in resp.get_json()["error"].lower()
def test_null_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": None}
)
assert resp.status_code == 400
def test_list_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": ["a", "b"]}
)
assert resp.status_code == 400
def test_dict_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": {"nested": True}}
)
assert resp.status_code == 400
def test_successful_quick_summary(self, authed_client):
mock_result = {"findings": [], "summary": "done"}
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.quick_summary",
return_value=mock_result,
):
with patch(
"local_deep_research.web.api.get_user_db_session"
) as inner_ctx:
inner_ctx.return_value.__enter__ = MagicMock(
return_value=None
)
inner_ctx.return_value.__exit__ = MagicMock(
return_value=None
)
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": "test"}
)
assert resp.status_code == 200
def test_error_summary_scrubbed_end_to_end(self, authed_client):
# CWE-209 / CodeQL #8019: drive the real route with the real
# quick_summary payload shape (error text arrives under `summary`,
# NOT `current_knowledge`) and assert the boundary scrub fires on
# what the client actually receives.
leaked = (
"Error: LLM call failed: "
"https://api.example.com/v1?api_key=sk-ENDTOEND1234567890AB"
)
mock_result = {
"research_id": "abc-123",
"summary": leaked,
"findings": [{"phase": "Error", "content": leaked}],
"iterations": 0,
"questions": {},
"formatted_findings": leaked,
"sources": [],
}
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.quick_summary",
return_value=mock_result,
):
with patch(
"local_deep_research.web.api.get_user_db_session"
) as inner_ctx:
inner_ctx.return_value.__enter__ = MagicMock(
return_value=None
)
inner_ctx.return_value.__exit__ = MagicMock(
return_value=None
)
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": "test"}
)
assert resp.status_code == 200
data = resp.get_json()
for field in ("summary", "formatted_findings"):
assert "sk-ENDTOEND1234567890AB" not in data[field]
assert data[field].startswith("Error:")
content = data["findings"][0]["content"]
assert "sk-ENDTOEND1234567890AB" not in content
assert content.startswith("Error:")
def test_timeout_returns_504(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.quick_summary",
side_effect=TimeoutError("slow"),
):
with patch(
"local_deep_research.web.api.get_user_db_session"
) as inner_ctx:
inner_ctx.return_value.__enter__ = MagicMock(
return_value=None
)
inner_ctx.return_value.__exit__ = MagicMock(
return_value=None
)
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": "slow query"}
)
assert resp.status_code == 504
assert "timed out" in resp.get_json()["error"].lower()
def test_generic_error_returns_500(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.quick_summary",
side_effect=ValueError("bad"),
):
with patch(
"local_deep_research.web.api.get_user_db_session"
) as inner_ctx:
inner_ctx.return_value.__enter__ = MagicMock(
return_value=None
)
inner_ctx.return_value.__exit__ = MagicMock(
return_value=None
)
resp = authed_client.post(
"/api/v1/quick_summary", json={"query": "fail"}
)
assert resp.status_code == 500
assert "internal error" in resp.get_json()["error"].lower()
def test_optional_params_forwarded(self, authed_client):
"""Extra params like search_tool, iterations, temperature should
be forwarded to the research function."""
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.quick_summary",
return_value={"findings": []},
) as mock_qs:
with patch(
"local_deep_research.web.api.get_user_db_session"
) as inner_ctx:
inner_ctx.return_value.__enter__ = MagicMock(
return_value=None
)
inner_ctx.return_value.__exit__ = MagicMock(
return_value=None
)
resp = authed_client.post(
"/api/v1/quick_summary",
json={
"query": "test",
"search_tool": "wikipedia",
"iterations": 3,
"temperature": 0.5,
},
)
assert resp.status_code == 200
call_kwargs = mock_qs.call_args
assert call_kwargs[0][0] == "test"
# The explicit values should override defaults
assert call_kwargs[1]["search_tool"] == "wikipedia"
assert call_kwargs[1]["iterations"] == 3
assert call_kwargs[1]["temperature"] == 0.5
def test_settings_snapshot_loaded(self, authed_client):
"""When the user has a valid db session, settings_snapshot is populated."""
mock_snapshot = {
"some.key": 42,
"another.key": "raw_value",
}
mock_sm_instance = MagicMock()
mock_sm_instance.get_settings_snapshot.return_value = mock_snapshot
# Also handle the api_access_control decorator calls
mock_sm_instance.get_setting.side_effect = lambda key, default: {
"app.enable_api": True,
}.get(key, default)
# The decorator uses module-level imports while the function body
# re-imports from the original modules. Patch both paths.
with (
patch(
"local_deep_research.web.api.get_user_db_session"
) as mock_ctx,
patch(
"local_deep_research.database.session_context.get_user_db_session",
mock_ctx,
),
patch(
"local_deep_research.web.api.get_settings_manager",
return_value=mock_sm_instance,
),
patch(
"local_deep_research.utilities.db_utils.get_settings_manager",
return_value=mock_sm_instance,
),
patch(
"local_deep_research.api.research_functions.quick_summary",
# autospec=True catches param renames on existing named
# args (e.g. username → user). quick_summary has **kwargs
# so unknown kwarg names are NOT rejected — for that
# bug class see TestResearchFunctionSignatures.
autospec=True,
) as mock_qs,
):
mock_qs.return_value = {"findings": []}
mock_db = MagicMock()
mock_ctx.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
resp = authed_client.post(
"/api/v1/quick_summary",
json={"query": "test"},
)
assert resp.status_code == 200
snapshot = mock_qs.call_args.kwargs.get("settings_snapshot")
assert snapshot is not None
assert snapshot["some.key"] == 42
assert snapshot["another.key"] == "raw_value"
# Contract: username + programmatic_mode=False also reach
# the research function. See _load_user_context_into_params
# in src/local_deep_research/web/api.py.
assert mock_qs.call_args.kwargs.get("username") == "testuser"
assert mock_qs.call_args.kwargs.get("programmatic_mode") is False
def test_settings_load_failure_fails_closed(self, authed_client):
"""If loading the settings snapshot fails, the request is REFUSED
(HTTP 503) rather than silently continuing with an empty snapshot.
Continuing with ``{}`` would resolve to the permissive BOTH scope,
downgrading a configured PRIVATE_ONLY / require-local user — so the
endpoint fails closed and the research function is never called.
We fail the endpoint's full-snapshot load (``get_settings_snapshot``) on the
shared settings-manager mock, so the auth decorator's ``get_setting``
calls keep working — only the snapshot build inside
``_load_user_context_into_params`` is broken. (Both
get_user_db_session and get_settings_manager are bound at module
level, so they share one patch surface.)
"""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.api.research_functions.quick_summary",
return_value={"findings": []},
) as mock_qs:
resp = authed_client.post(
"/api/v1/quick_summary",
json={"query": "test"},
)
assert resp.status_code == 503
mock_qs.assert_not_called()
# The 503 carries actionable guidance, not just a code.
body = resp.get_json()
assert "how_to_fix" in body
assert "allow_default_settings" in body["how_to_fix"]
def test_settings_load_failure_opt_in_continues_with_defaults(
self, authed_client
):
"""With ``allow_default_settings=true`` the caller consciously opts in
to run with defaults (empty snapshot) when settings can't load — the
request proceeds (200) instead of failing closed, and quick_summary is
called with an empty settings_snapshot."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.api.research_functions.quick_summary",
return_value={"findings": []},
) as mock_qs:
resp = authed_client.post(
"/api/v1/quick_summary",
json={"query": "test", "allow_default_settings": True},
)
assert resp.status_code == 200
mock_qs.assert_called_once()
assert mock_qs.call_args.kwargs["settings_snapshot"] == {}
# The opt-in flag must NOT be forwarded to quick_summary.
assert "allow_default_settings" not in mock_qs.call_args.kwargs
def test_opt_in_requires_real_true_not_truthy_string(self, authed_client):
"""Security-boundary flag: a truthy STRING like "false" must NOT opt in
— only a real JSON ``true`` does. Otherwise it still fails closed."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.api.research_functions.quick_summary",
return_value={"findings": []},
) as mock_qs:
resp = authed_client.post(
"/api/v1/quick_summary",
# JSON string "false" is truthy but is not boolean true.
json={"query": "t", "allow_default_settings": "false"},
)
assert resp.status_code == 503
mock_qs.assert_not_called()
def test_opt_in_path_emits_policy_audit_warning(self, authed_client):
"""The opt-in (run-without-settings) path must log a loud policy_audit
warning — the security claim is that it is never silent."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with (
patch(
"local_deep_research.api.research_functions.quick_summary",
return_value={"findings": []},
),
patch("local_deep_research.web.api.logger") as mock_logger,
):
resp = authed_client.post(
"/api/v1/quick_summary",
json={"query": "t", "allow_default_settings": True},
)
assert resp.status_code == 200
# logger.bind(policy_audit=True) was used …
bind_kwargs = [
c.kwargs for c in mock_logger.bind.call_args_list
]
assert {"policy_audit": True} in bind_kwargs
# … and the bound logger emitted a warning naming the opt-in.
bound = mock_logger.bind.return_value
assert any(
"DEFAULT settings" in str(c)
for c in bound.warning.call_args_list
)
def test_non_dict_body_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/quick_summary",
data="[1]",
content_type="application/json",
)
assert resp.status_code == 400
# ===================================================================
# /api/v1/generate_report
# ===================================================================
class TestGenerateReport:
"""POST /api/v1/generate_report"""
def test_unauthenticated_returns_401(self, client):
resp = client.post("/api/v1/generate_report", json={"query": "hi"})
assert resp.status_code == 401
def test_no_json_body_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/generate_report", content_type="application/json"
)
assert resp.status_code == 400
def test_missing_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/generate_report", json={"temperature": 0.5}
)
assert resp.status_code == 400
def test_successful_report(self, authed_client):
mock_result = {"content": "short report", "title": "Report"}
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value=mock_result,
):
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "test"}
)
assert resp.status_code == 200
data = resp.get_json()
assert data["content"] == "short report"
assert data["title"] == "Report"
def test_large_report_is_truncated(self, authed_client):
long_content = "x" * 15000
mock_result = {"content": long_content, "title": "Big Report"}
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value=mock_result,
):
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "big"}
)
assert resp.status_code == 200
data = resp.get_json()
assert data["content_truncated"] is True
assert len(data["content"]) < 15000
assert data["content"].endswith("... [Content truncated]")
def test_content_exactly_10000_not_truncated(self, authed_client):
content = "a" * 10000
mock_result = {"content": content}
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value=mock_result,
):
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "q"}
)
data = resp.get_json()
assert "content_truncated" not in data
assert len(data["content"]) == 10000
def test_timeout_returns_504(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
side_effect=TimeoutError("slow"),
):
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "slow"}
)
assert resp.status_code == 504
def test_generic_error_returns_500(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
side_effect=RuntimeError("boom"),
):
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "fail"}
)
assert resp.status_code == 500
def test_optional_params_forwarded(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value={"content": "ok"},
) as mock_gr:
resp = authed_client.post(
"/api/v1/generate_report",
json={
"query": "test",
"output_file": "/tmp/out.md",
"searches_per_section": 5,
"model_name": "gpt-4",
"temperature": 0.3,
},
)
assert resp.status_code == 200
kw = mock_gr.call_args[1]
assert kw["output_file"] == "/tmp/out.md"
assert kw["model_name"] == "gpt-4"
assert kw["temperature"] == 0.3
assert kw["searches_per_section"] == 5
def test_default_params_applied(self, authed_client):
"""searches_per_section and temperature get defaults if omitted."""
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value={"content": "ok"},
) as mock_gr:
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "test"}
)
assert resp.status_code == 200
kw = mock_gr.call_args[1]
assert kw["searches_per_section"] == 1
assert kw["temperature"] == 0.7
def test_non_dict_body_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/generate_report",
data='"just a string"',
content_type="application/json",
)
assert resp.status_code == 400
def test_result_none_content_not_truncated(self, authed_client):
"""If content is not a string (e.g. None), truncation is skipped."""
mock_result = {"content": None, "title": "T"}
with _mock_access_control():
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value=mock_result,
):
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "q"}
)
assert resp.status_code == 200
assert resp.get_json()["content"] is None
def test_user_context_loaded(self, authed_client):
"""Authenticated requests must thread username + settings_snapshot
+ programmatic_mode=False down to generate_report. Pre-fix this
endpoint silently dropped user context, so users' encrypted-DB
API keys / model preferences / search tool config never reached
the research function."""
mock_snapshot = {
"llm.provider": "openai",
"search.tool": "tavily",
}
mock_sm_instance = MagicMock()
mock_sm_instance.get_settings_snapshot.return_value = mock_snapshot
mock_sm_instance.get_setting.side_effect = lambda key, default: {
"app.enable_api": True,
}.get(key, default)
with (
patch(
"local_deep_research.web.api.get_user_db_session"
) as mock_ctx,
patch(
"local_deep_research.web.api.get_settings_manager",
return_value=mock_sm_instance,
),
patch(
"local_deep_research.api.research_functions.generate_report",
# autospec=True catches param renames on existing named
# args. generate_report has **kwargs so unknown kwarg
# names are NOT rejected — TestResearchFunctionSignatures
# covers that bug class via sig.bind_partial.
autospec=True,
) as mock_gr,
):
mock_gr.return_value = {"content": "ok"}
mock_ctx.return_value.__enter__ = MagicMock(
return_value=MagicMock()
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "test"}
)
assert resp.status_code == 200
kw = mock_gr.call_args.kwargs
assert kw.get("username") == "testuser"
assert kw.get("programmatic_mode") is False
snapshot = kw.get("settings_snapshot")
assert snapshot is not None
assert snapshot["llm.provider"] == "openai"
assert snapshot["search.tool"] == "tavily"
def test_settings_load_failure_fails_closed(self, authed_client):
"""Like /quick_summary, /generate_report refuses (503) when the
settings snapshot can't be loaded, instead of silently running
with defaults — same egress-policy boundary, same helper."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value={"content": "ok"},
) as mock_gr:
resp = authed_client.post(
"/api/v1/generate_report",
json={"query": "test"},
)
assert resp.status_code == 503
mock_gr.assert_not_called()
assert "how_to_fix" in resp.get_json()
def test_settings_load_failure_opt_in_continues_with_defaults(
self, authed_client
):
"""allow_default_settings=true opts in to run with an empty
snapshot when settings can't load; the flag itself must not be
forwarded to generate_report."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.api.research_functions.generate_report",
return_value={"content": "ok"},
) as mock_gr:
resp = authed_client.post(
"/api/v1/generate_report",
json={"query": "test", "allow_default_settings": True},
)
assert resp.status_code == 200
mock_gr.assert_called_once()
assert mock_gr.call_args.kwargs["settings_snapshot"] == {}
assert "allow_default_settings" not in mock_gr.call_args.kwargs
# ===================================================================
# /api/v1/analyze_documents
# ===================================================================
class TestAnalyzeDocuments:
"""POST /api/v1/analyze_documents"""
def test_unauthenticated_returns_401(self, client):
resp = client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "c"},
)
assert resp.status_code == 401
def test_no_json_body_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/analyze_documents", content_type="application/json"
)
assert resp.status_code == 400
def test_missing_query_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/analyze_documents", json={"collection_name": "c"}
)
assert resp.status_code == 400
def test_missing_collection_name_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/analyze_documents", json={"query": "q"}
)
assert resp.status_code == 400
def test_missing_both_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/analyze_documents", json={"temperature": 0.5}
)
assert resp.status_code == 400
assert "both" in resp.get_json()["error"].lower()
def test_successful_analyze(self, authed_client):
mock_result = {"analysis": "done", "documents": []}
with _mock_access_control():
with patch(
"local_deep_research.web.api.analyze_documents",
return_value=mock_result,
):
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "neural nets", "collection_name": "papers"},
)
assert resp.status_code == 200
assert resp.get_json() == mock_result
def test_error_summary_scrubbed_end_to_end(self, authed_client):
# CWE-209 / CodeQL #8019: analyze_documents puts "Error: ..." text
# in `summary` (its real payload field) — assert the boundary
# scrub fires on the actual route response.
leaked = (
"Error: collection lookup failed: "
"https://db.example.com:5432/db?password=supersecret123"
)
with _mock_access_control():
with patch(
"local_deep_research.web.api.analyze_documents",
return_value={"summary": leaked, "documents": []},
):
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "papers"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "supersecret123" not in data["summary"]
assert data["summary"].startswith("Error:")
def test_extra_params_forwarded(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.web.api.analyze_documents",
return_value={},
) as mock_ad:
resp = authed_client.post(
"/api/v1/analyze_documents",
json={
"query": "q",
"collection_name": "c",
"max_results": 10,
"temperature": 0.3,
"force_reindex": True,
},
)
assert resp.status_code == 200
kw = mock_ad.call_args[1]
assert kw["max_results"] == 10
assert kw["temperature"] == 0.3
assert kw["force_reindex"] is True
def test_unknown_param_returns_400(self, authed_client):
"""analyze_documents has no **kwargs, so an unknown body key would
TypeError at call time and surface as an opaque 500. The endpoint
must reject it up front with a 400 naming the parameter."""
with _mock_access_control():
with patch(
"local_deep_research.web.api.analyze_documents",
return_value={},
) as mock_ad:
resp = authed_client.post(
"/api/v1/analyze_documents",
json={
"query": "q",
"collection_name": "c",
"max_result": 5, # typo: should be max_results
},
)
assert resp.status_code == 400
mock_ad.assert_not_called()
body = resp.get_json()
assert "max_result" in body["error"]
assert "max_results" in body["allowed_parameters"]
@pytest.mark.parametrize("key", ["username", "settings_snapshot"])
def test_server_set_params_rejected_in_body(self, authed_client, key):
"""username/settings_snapshot are set server-side by
_load_user_context_into_params; a body that supplies them is
rejected rather than silently overwritten."""
with _mock_access_control():
with patch(
"local_deep_research.web.api.analyze_documents",
return_value={},
) as mock_ad:
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "c", key: "x"},
)
assert resp.status_code == 400
mock_ad.assert_not_called()
def test_error_returns_500(self, authed_client):
with _mock_access_control():
with patch(
"local_deep_research.web.api.analyze_documents",
side_effect=RuntimeError("boom"),
):
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "c"},
)
assert resp.status_code == 500
assert "internal error" in resp.get_json()["error"].lower()
def test_non_dict_body_returns_400(self, authed_client):
with _mock_access_control():
resp = authed_client.post(
"/api/v1/analyze_documents",
data="42",
content_type="application/json",
)
assert resp.status_code == 400
def test_user_context_loaded(self, authed_client):
"""Authenticated requests must thread username + settings_snapshot
+ programmatic_mode=False down to analyze_documents. Pre-fix this
endpoint silently dropped user context, so users' encrypted-DB
embedding model / collection settings never reached the research
function."""
mock_snapshot = {
"rag.embedding_model": "BAAI/bge-base",
}
mock_sm_instance = MagicMock()
mock_sm_instance.get_settings_snapshot.return_value = mock_snapshot
mock_sm_instance.get_setting.side_effect = lambda key, default: {
"app.enable_api": True,
}.get(key, default)
with (
patch(
"local_deep_research.web.api.get_user_db_session"
) as mock_ctx,
patch(
"local_deep_research.web.api.get_settings_manager",
return_value=mock_sm_instance,
),
patch(
"local_deep_research.web.api.analyze_documents",
# autospec=True so the mock mirrors the real function's
# signature; if the endpoint passes kwargs the function
# doesn't accept, the mock raises TypeError just like
# the real call would. Caught the original /analyze_documents
# bug where MagicMock(return_value={}) silently swallowed
# incompatible kwargs.
autospec=True,
) as mock_ad,
):
mock_ad.return_value = {}
mock_ctx.return_value.__enter__ = MagicMock(
return_value=MagicMock()
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "c"},
)
assert resp.status_code == 200
kw = mock_ad.call_args.kwargs
assert kw.get("username") == "testuser"
assert kw.get("programmatic_mode") is False
snapshot = kw.get("settings_snapshot")
assert snapshot is not None
assert snapshot["rag.embedding_model"] == "BAAI/bge-base"
def test_settings_load_failure_fails_closed(self, authed_client):
"""Like /quick_summary, /analyze_documents refuses (503) when the
settings snapshot can't be loaded, instead of silently running
with defaults — same egress-policy boundary, same helper."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.web.api.analyze_documents",
return_value={},
) as mock_ad:
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "c"},
)
assert resp.status_code == 503
mock_ad.assert_not_called()
assert "how_to_fix" in resp.get_json()
def test_settings_load_failure_opt_in_continues_with_defaults(
self, authed_client
):
"""allow_default_settings=true opts in to run with an empty
snapshot when settings can't load; the flag itself must not be
forwarded to analyze_documents."""
with _mock_access_control() as (_ctx, mock_sm):
mock_sm.return_value.get_settings_snapshot.side_effect = (
RuntimeError("settings fail")
)
with patch(
"local_deep_research.web.api.analyze_documents",
return_value={},
) as mock_ad:
resp = authed_client.post(
"/api/v1/analyze_documents",
json={
"query": "q",
"collection_name": "c",
"allow_default_settings": True,
},
)
assert resp.status_code == 200
mock_ad.assert_called_once()
assert mock_ad.call_args.kwargs["settings_snapshot"] == {}
assert "allow_default_settings" not in mock_ad.call_args.kwargs
# ===================================================================
# End-to-end REST → research-function call path
# ===================================================================
class TestRestToResearchFunctionCallPath:
"""Real-path tests that DO NOT mock the research functions themselves.
Instead they mock the LLM and search-engine factories one layer down,
so the actual REST handler → research function → signature-unpack
→ function-body chain runs.
Catches the bug class where the REST handler passes kwargs the
research function does not accept. Mock-based tests at the
web.api.<research_fn> boundary use MagicMock which silently
swallows any kwargs and never raises TypeError. The end-to-end
path test in tests/api_tests/test_rest_api.py is marked
@requires_llm and therefore auto-skipped in CI (which runs with
LDR_TESTING_WITH_MOCKS=true), so until now no test verified the
REST endpoint could actually invoke the research function with
the kwargs it passes.
"""
def test_analyze_documents_full_call_path(self, authed_client):
"""Fires a real /api/v1/analyze_documents request. Does NOT
mock analyze_documents itself — only get_llm and get_search.
The real analyze_documents() body executes; if the REST
endpoint passes kwargs it does not accept, this surfaces as
a 500 (caught by the handler's broad except), not a silent
green test."""
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.content = "stub summary"
mock_llm.invoke.return_value = mock_response
mock_search = MagicMock()
mock_search.run.return_value = [
{"content": "stub document content", "title": "Stub Doc"}
]
with _mock_access_control():
with (
# autospec=True so analyze_documents' calls into
# get_llm/get_search are validated against the real
# signatures — same kwargs-swallowing blind spot the
# endpoint-level tests close, one layer down.
patch(
"local_deep_research.api.research_functions.get_llm",
autospec=True,
return_value=mock_llm,
),
patch(
"local_deep_research.api.research_functions.get_search",
autospec=True,
return_value=mock_search,
),
):
resp = authed_client.post(
"/api/v1/analyze_documents",
json={"query": "q", "collection_name": "c"},
)
assert resp.status_code == 200, (
f"REST endpoint failed to invoke analyze_documents() "
f"with the kwargs it passes. Response: {resp.get_data(as_text=True)}"
)
data = resp.get_json()
assert "summary" in data
# ---------------------------------------------------------------
# Stub data shared across the per-output assertions below. The
# LLM content includes [1]/[2] citations so the test also covers
# the "summary preserves citation markers" passthrough — if a
# future refactor adds citation normalization to analyze_documents,
# the citation-passthrough test below will flag it for review.
# ---------------------------------------------------------------
_QUERY = "What is quantum computing?"
_COLLECTION = "physics_papers"
_STUB_LLM_SUMMARY = "Qubits [1] enable superposition and entanglement [2]."
_STUB_DOCUMENTS = [
{
"content": "Qubits are quantum bits.",
"title": "Qubit Basics",
"link": "https://example.com/qubit",
},
{
"content": "Superposition allows simultaneous states.",
"title": "Superposition",
"link": "https://example.com/super",
},
]
def _post_analyze_documents(self, authed_client):
"""Fire a real /api/v1/analyze_documents request with the
stub LLM + stub search above. Returns (response, mock_llm,
mock_search) so individual tests can assert on whichever
slice they care about."""
mock_llm = MagicMock()
mock_llm_response = MagicMock()
mock_llm_response.content = self._STUB_LLM_SUMMARY
mock_llm.invoke.return_value = mock_llm_response
mock_search = MagicMock()
mock_search.run.return_value = self._STUB_DOCUMENTS
with _mock_access_control():
with (
# autospec=True validates analyze_documents' calls into
# get_llm/get_search against the real signatures.
patch(
"local_deep_research.api.research_functions.get_llm",
autospec=True,
return_value=mock_llm,
),
patch(
"local_deep_research.api.research_functions.get_search",
autospec=True,
return_value=mock_search,
),
):
resp = authed_client.post(
"/api/v1/analyze_documents",
json={
"query": self._QUERY,
"collection_name": self._COLLECTION,
},
)
return resp, mock_llm, mock_search
# --- Per-output assertions (one test per response field) -------
def test_analyze_documents_response_shape_exact(self, authed_client):
"""The full response equals an exact predetermined dict — no
unexpected keys, no missing keys. Catches new fields silently
added or existing fields silently removed."""
resp, _, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
assert resp.get_json() == {
"summary": self._STUB_LLM_SUMMARY,
"documents": self._STUB_DOCUMENTS,
"collection": self._COLLECTION,
"document_count": len(self._STUB_DOCUMENTS),
}
def test_analyze_documents_summary_equals_llm_content(self, authed_client):
"""The ``summary`` field must equal the LLM's ``.content``
verbatim (modulo the no-op ``remove_think_tags`` transform —
our stub content has no <think> tags)."""
resp, _, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
assert resp.get_json()["summary"] == self._STUB_LLM_SUMMARY
def test_analyze_documents_summary_preserves_citations(self, authed_client):
"""``[1]``/``[2]`` citation markers in the LLM response must
reach the client unchanged. analyze_documents() must not strip
or rewrite them. If a future refactor adds citation processing
here, this test will flag the behavior change."""
resp, _, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
summary = resp.get_json()["summary"]
assert "[1]" in summary, (
f"Citation [1] was stripped from summary: {summary!r}"
)
assert "[2]" in summary, (
f"Citation [2] was stripped from summary: {summary!r}"
)
def test_analyze_documents_documents_field_passes_through(
self, authed_client
):
"""The ``documents`` field equals the search engine's output
verbatim — analyze_documents() does not filter, reorder, or
mutate it."""
resp, _, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
assert resp.get_json()["documents"] == self._STUB_DOCUMENTS
def test_analyze_documents_collection_echoed(self, authed_client):
"""The ``collection`` field equals the collection name the
client requested."""
resp, _, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
assert resp.get_json()["collection"] == self._COLLECTION
def test_analyze_documents_document_count_matches(self, authed_client):
"""The ``document_count`` field equals ``len(documents)``."""
resp, _, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
assert resp.get_json()["document_count"] == len(self._STUB_DOCUMENTS)
# --- Inputs flowed to LLM/search assertions --------------------
def test_analyze_documents_search_called_with_query(self, authed_client):
"""The search engine receives the user's query verbatim."""
resp, _, mock_search = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
mock_search.run.assert_called_once_with(self._QUERY)
def test_analyze_documents_llm_prompt_includes_query(self, authed_client):
"""The LLM's summarisation prompt embeds the user's query."""
resp, mock_llm, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
prompt = mock_llm.invoke.call_args[0][0]
assert self._QUERY in prompt
def test_analyze_documents_llm_prompt_includes_documents(
self, authed_client
):
"""The LLM's prompt embeds each stub document's content."""
resp, mock_llm, _ = self._post_analyze_documents(authed_client)
assert resp.status_code == 200
prompt = mock_llm.invoke.call_args[0][0]
for doc in self._STUB_DOCUMENTS:
assert doc["content"] in prompt, (
f"Document content missing from LLM prompt: {doc['content']!r}"
)
def test_analyze_documents_output_file_branch_passes_snapshot(
self, authed_client
):
"""When ``output_file`` is in the request body, ``analyze_documents``
calls ``write_file_verified`` to enforce the user's
``api.allow_file_output`` setting. Verify the user's
``settings_snapshot`` reaches ``write_file_verified`` so the
setting check uses user config, not JSON defaults / env vars.
This is the file-write branch of ``analyze_documents`` that the
other end-to-end tests skip because they don't pass
``output_file``. Locks in the third of the four coordinated
threadings (signature → get_llm → get_search → write_file_verified)
— without this, a future revert of the ``settings_snapshot=None``
line at ``research_functions.py`` would silently regress the
file-output gate to ignore the user's setting.
"""
write_call = {}
def _capture_write(*args, **kwargs):
write_call["args"] = args
write_call["kwargs"] = kwargs
# Return None — the real write_file_verified returns
# nothing on the no-op path, and analyze_documents only
# uses the side effect (file write).
return
mock_llm = MagicMock()
mock_llm_response = MagicMock()
mock_llm_response.content = self._STUB_LLM_SUMMARY
mock_llm.invoke.return_value = mock_llm_response
mock_search = MagicMock()
mock_search.run.return_value = self._STUB_DOCUMENTS
with _mock_access_control():
with (
# autospec=True validates analyze_documents' calls into
# get_llm/get_search against the real signatures.
patch(
"local_deep_research.api.research_functions.get_llm",
autospec=True,
return_value=mock_llm,
),
patch(
"local_deep_research.api.research_functions.get_search",
autospec=True,
return_value=mock_search,
),
patch(
"local_deep_research.security.file_write_verifier.write_file_verified",
side_effect=_capture_write,
),
):
resp = authed_client.post(
"/api/v1/analyze_documents",
json={
"query": self._QUERY,
"collection_name": self._COLLECTION,
"output_file": "/tmp/ldr_test_output.md",
},
)
assert resp.status_code == 200
assert write_call, (
"write_file_verified was not called — analyze_documents may "
"have skipped the output_file branch entirely."
)
snapshot = write_call["kwargs"].get("settings_snapshot")
assert snapshot is not None, (
"write_file_verified got settings_snapshot=None — user's "
"api.allow_file_output setting is ignored on the file-write "
"branch."
)
assert snapshot.get("_ldr_test_tracer") == "tracer-value", (
f"settings_snapshot reaching write_file_verified does not "
f"match the user's snapshot. Got: {snapshot!r}"
)
# The setting key write_file_verified is asked to enforce.
# Pinning it ensures the file-output gate isn't accidentally
# rerouted to a different (or no) setting.
assert "api.allow_file_output" in write_call["args"], (
f"write_file_verified called without 'api.allow_file_output' "
f"setting key. args: {write_call['args']!r}"
)
# ---------------------------------------------------------------
# /generate_report real-path tests (issue #4396)
# ---------------------------------------------------------------
# Unlike analyze_documents (a flat get_search -> get_llm -> summarize
# function), generate_report runs the whole research engine
# (_init_search_system -> AdvancedSearchSystem.analyze_topic ->
# IntegratedReportGenerator), so get_llm/get_search alone is the wrong
# seam — it would run the full engine. The deepest function specific to
# the generate_report wrapper is _init_search_system, and its
# get_llm(settings_snapshot=...) call (research_functions.py) is the
# EXACT line that 500'd in #4396 when the REST endpoint failed to inject
# the user's snapshot (no provider/api_key -> LLM init failure). So mock
# _init_search_system + IntegratedReportGenerator and let the real
# generate_report() body run: snapshot threading, search-context setup,
# report assembly, and the REST handler's truncation/response logic.
_GR_REPORT = {"content": "Final report body.", "metadata": {"query": "q"}}
def _post_generate_report(self, authed_client):
"""Fire a real /api/v1/generate_report request WITHOUT mocking
generate_report itself — only _init_search_system (one layer down,
whose get_llm call is the #4396 failure point) and the report
generator. Returns ``(response, init_kwargs)`` where ``init_kwargs``
is the kwargs _init_search_system actually received (or None if it
was never called), so individual tests assert on the response or on
the user context that reached it."""
captured = {}
def _capture_init(*args, **kwargs):
captured["kwargs"] = kwargs
stub_system = MagicMock()
stub_system.analyze_topic.return_value = {
"findings": [],
"current_knowledge": "",
}
return stub_system
with _mock_access_control():
with (
patch(
"local_deep_research.api.research_functions._init_search_system",
side_effect=_capture_init,
),
patch(
"local_deep_research.api.research_functions.IntegratedReportGenerator"
) as mock_rg_cls,
# _close_system runs in generate_report's finally; with a
# MagicMock system it would call safe_close on auto-created
# attributes. No-op it to keep the test focused.
patch(
"local_deep_research.api.research_functions._close_system"
),
):
mock_rg_cls.return_value.generate_report.return_value = (
self._GR_REPORT
)
resp = authed_client.post(
"/api/v1/generate_report", json={"query": "q"}
)
return resp, captured.get("kwargs")
def test_generate_report_full_call_path(self, authed_client):
"""The real generate_report() body runs end-to-end via REST (only
_init_search_system + the report generator are mocked) and returns
200 — not the original #4396 500 — with the report content passed
back to the client. The per-input user-context assertions live in
the next test."""
resp, _ = self._post_generate_report(authed_client)
assert resp.status_code == 200, (
f"REST endpoint failed to invoke generate_report() end-to-end. "
f"Issue #4396 was a 500 here (no provider/api_key reached LLM "
f"init). Response: {resp.get_data(as_text=True)}"
)
assert resp.get_json()["content"] == self._GR_REPORT["content"]
def test_generate_report_threads_user_context_to_search_system(
self, authed_client
):
"""The #4396 fix, verified at the failure site: the authenticated
user's settings_snapshot (and username) must reach
_init_search_system, whose get_llm(settings_snapshot=...) call is
what raised when the snapshot was missing. A future revert of the
user-context injection in api_generate_report fails loudly here —
not as a silent green mock test."""
resp, init_kwargs = self._post_generate_report(authed_client)
assert resp.status_code == 200, resp.get_data(as_text=True)
assert init_kwargs is not None, (
"_init_search_system was never called — generate_report "
"returned 200 without initializing the search system."
)
snapshot = init_kwargs.get("settings_snapshot")
assert snapshot is not None, (
"_init_search_system received no settings_snapshot — this is "
"exactly the #4396 regression (no provider/api_key -> LLM init "
"500)."
)
assert snapshot.get("_ldr_test_tracer") == "tracer-value", (
f"settings_snapshot reaching _init_search_system is not the "
f"user's snapshot. Got: {snapshot!r}"
)
assert init_kwargs.get("username") == "testuser", (
"username did not reach _init_search_system"
)
# ===================================================================
# Research-function signature compatibility
# ===================================================================
class TestResearchFunctionSignatures:
"""Static checks that the programmatic-API research functions accept
the kwargs that authed REST endpoints pass through
``_load_user_context_into_params``. No mocking — operates purely on
``inspect.signature``. Catches the bug class where an endpoint passes
``**params`` to a research function whose signature doesn't accept the
keys, which would TypeError at runtime but pass mock-based tests
(``MagicMock(return_value=...)`` swallows arbitrary kwargs).
"""
@pytest.mark.parametrize(
"fn_name,extra_required",
[
("quick_summary", {}),
("generate_report", {}),
("analyze_documents", {"collection_name": "c"}),
],
)
def test_endpoint_call_binds_to_function_signature(
self, fn_name, extra_required
):
"""Reproduce the exact call shape REST endpoints use:
``fn(query=..., **params)`` where params contains the keys
``_load_user_context_into_params`` writes. ``sig.bind_partial``
raises TypeError if any kwarg is rejected by the signature —
catches the bug class for all three functions, including ones
with ``**kwargs`` where a plain "is the kwarg accepted" check
would short-circuit on ``has_var_keyword=True`` and never
actually validate.
"""
import inspect
from local_deep_research.api import research_functions
fn = getattr(research_functions, fn_name)
sig = inspect.signature(fn)
endpoint_call_kwargs = {
"query": "q",
"username": "u",
"settings_snapshot": {},
"programmatic_mode": False,
**extra_required,
}
try:
sig.bind_partial(**endpoint_call_kwargs)
except TypeError as exc:
pytest.fail(
f"REST endpoint cannot call {fn_name}{sig}: {exc}. "
f"This bug class — endpoint passes kwargs the function "
f"rejects — caused the original /analyze_documents "
f"runtime TypeError that mock-based tests missed."
)
# ===================================================================
# Endpoint completeness: every research endpoint threads user context
# ===================================================================
# Contract registry for TestEndpointUserContextCompleteness. Maps each
# POST view function on the api_v1 blueprint to (patch target for its
# research function, minimal valid request body, stub return value).
# A NEW POST endpoint added to the blueprint fails
# test_every_post_endpoint_has_contract until it gets an entry here —
# which routes it through test_endpoint_threads_user_context and
# therefore through _load_user_context_into_params. See the contract
# comment above that helper in src/local_deep_research/web/api.py.
#
# Escape hatch: an endpoint that genuinely calls NO research function
# maps to None, with a comment on its entry explaining why — the
# exemption is then visible and reviewable here instead of the endpoint
# being silently absent. None entries are skipped by
# test_endpoint_threads_user_context but still checked for staleness.
_ENDPOINT_CONTRACTS = {
"api_quick_summary": (
"local_deep_research.api.research_functions.quick_summary",
{"query": "q"},
{"findings": []},
),
"api_generate_report": (
"local_deep_research.api.research_functions.generate_report",
{"query": "q"},
{"content": "ok"},
),
"api_analyze_documents": (
# analyze_documents is imported at module level in web.api, so
# the patch target is the web.api binding, not research_functions.
"local_deep_research.web.api.analyze_documents",
{"query": "q", "collection_name": "c"},
{},
),
}
class TestEndpointUserContextCompleteness:
"""Guard against the next variant of the original #3661 bug: a NEW
endpoint that calls a research function but never loads the user's
encrypted-DB context.
The per-endpoint contract tests above can only cover endpoints that
exist today. This class iterates the api_v1 blueprint's actual URL
map (not the route registry, which could drift from the blueprint),
so a POST endpoint added to the blueprint without a contract entry
fails loudly here instead of shipping silently uncovered.
"""
@staticmethod
def _post_view_names(app):
return sorted(
rule.endpoint.removeprefix("api_v1.")
for rule in app.url_map.iter_rules()
if rule.endpoint.startswith("api_v1.") and "POST" in rule.methods
)
def test_every_post_endpoint_has_contract(self, app):
missing = [
name
for name in self._post_view_names(app)
if name not in _ENDPOINT_CONTRACTS
]
assert not missing, (
f"POST endpoint(s) on the api_v1 blueprint have no entry in "
f"_ENDPOINT_CONTRACTS: {missing}. Every research-calling REST "
f"endpoint must invoke _load_user_context_into_params (see the "
f"contract comment in web/api.py) and be wired into this "
f"contract registry so test_endpoint_threads_user_context "
f"verifies the user's context actually reaches the research "
f"function. If the new endpoint genuinely calls no research "
f"function, map it to None with a comment explaining why."
)
def test_no_stale_contract_entries(self, app):
registered = set(self._post_view_names(app))
stale = [name for name in _ENDPOINT_CONTRACTS if name not in registered]
assert not stale, (
f"_ENDPOINT_CONTRACTS lists endpoint(s) that no longer exist "
f"on the api_v1 blueprint: {stale}. Remove their entries."
)
@pytest.mark.parametrize(
"view_name",
sorted(
name
for name, contract in _ENDPOINT_CONTRACTS.items()
if contract is not None
),
)
def test_endpoint_threads_user_context(self, authed_client, app, view_name):
"""Generic contract: an authenticated POST to the endpoint must
deliver username, the user's settings snapshot (tracer key from
_mock_access_control), and programmatic_mode=False to its
research function."""
patch_target, body, stub_return = _ENDPOINT_CONTRACTS[view_name]
rule = next(
r
for r in app.url_map.iter_rules()
if r.endpoint == f"api_v1.{view_name}"
)
with _mock_access_control():
with patch(patch_target, return_value=stub_return) as mock_fn:
resp = authed_client.post(rule.rule, json=body)
assert resp.status_code == 200, (
f"{view_name} returned {resp.status_code}: "
f"{resp.get_data(as_text=True)}"
)
kwargs = mock_fn.call_args.kwargs
assert kwargs.get("username") == "testuser", (
f"{view_name} did not pass the authenticated username "
f"to its research function"
)
assert kwargs.get("programmatic_mode") is False, (
f"{view_name} did not pass programmatic_mode=False"
)
snapshot = kwargs.get("settings_snapshot")
assert snapshot is not None, (
f"{view_name} did not pass settings_snapshot"
)
assert snapshot.get("_ldr_test_tracer") == "tracer-value", (
f"{view_name} passed a settings_snapshot that is not "
f"the user's snapshot. Got: {snapshot!r}"
)
# ===================================================================
# _serialize_results helper
# ===================================================================
class TestSerializeResults:
"""Unit tests for the _serialize_results helper."""
def test_converts_documents(self, app):
from local_deep_research.web.api import _serialize_results
mock_doc = MagicMock()
mock_doc.metadata = {"source": "wiki"}
mock_doc.page_content = "some text"
results = {
"findings": [{"documents": [mock_doc], "query": "test"}],
"summary": "ok",
}
with app.app_context():
resp = _serialize_results(results)
data = resp.get_json()
assert data["summary"] == "ok"
doc = data["findings"][0]["documents"][0]
assert doc["metadata"] == {"source": "wiki"}
assert doc["content"] == "some text"
def test_no_findings_key(self, app):
from local_deep_research.web.api import _serialize_results
results = {"summary": "nothing"}
with app.app_context():
resp = _serialize_results(results)
assert resp.get_json() == {"summary": "nothing"}
def test_empty_findings(self, app):
from local_deep_research.web.api import _serialize_results
results = {"findings": []}
with app.app_context():
resp = _serialize_results(results)
assert resp.get_json()["findings"] == []
def test_finding_without_documents(self, app):
from local_deep_research.web.api import _serialize_results
results = {"findings": [{"query": "test"}]}
with app.app_context():
resp = _serialize_results(results)
assert resp.get_json()["findings"][0]["query"] == "test"
def test_does_not_mutate_original(self, app):
from local_deep_research.web.api import _serialize_results
mock_doc = MagicMock()
mock_doc.metadata = {"a": 1}
mock_doc.page_content = "text"
original_findings = [{"documents": [mock_doc]}]
results = {"findings": original_findings}
with app.app_context():
_serialize_results(results)
# Original list should still contain the mock object
assert original_findings[0]["documents"][0] is not None
# -- CWE-209 / CodeQL #8019: scrub exception-derived fields at the
# HTTP boundary so credentials and stack-trace text cannot leak via
# the API path (which bypasses the web-UI ErrorReportGenerator).
def test_error_prefixed_current_knowledge_is_scrubbed(self, app):
from local_deep_research.web.api import _serialize_results
# API key glued into an exception message (realistic: many HTTP
# library errors echo the request URL).
leaked = (
"Error: LLM call failed: "
"https://api.example.com/v1?api_key=sk-SECRETKEY1234567890AB"
)
results = {"current_knowledge": leaked}
with app.app_context():
data = _serialize_results(results).get_json()
assert "sk-SECRETKEY1234567890AB" not in data["current_knowledge"]
assert data["current_knowledge"].startswith("Error:")
def test_error_prefixed_summary_is_scrubbed_real_payload_shape(self, app):
from local_deep_research.web.api import _serialize_results
# quick_summary() returns the strategy's current_knowledge under
# the key `summary` (research_functions.py) — the field an API
# client actually receives. Regression guard for the field-name
# mismatch where only `current_knowledge` was scrubbed and the
# real payload leaked through untouched.
leaked = (
"Error: LLM call failed: "
"https://api.example.com/v1?api_key=sk-REALSHAPE12345678901"
)
results = {
"research_id": "abc-123",
"summary": leaked,
"findings": [],
"iterations": 1,
"questions": {},
"formatted_findings": "",
"sources": [],
}
with app.app_context():
data = _serialize_results(results).get_json()
assert "sk-REALSHAPE12345678901" not in data["summary"]
assert data["summary"].startswith("Error:")
def test_bare_api_key_in_prose_is_scrubbed(self, app):
from local_deep_research.web.api import _serialize_results
# Not URL-embedded: exercises the sk-* prefix regex directly
# (>= 20 chars after the prefix), not the ?api_key= query-param
# pattern the other fixtures rely on.
leaked = (
"Error: OpenAI authentication failed for key "
"sk-proj-AbCdEfGhIjKlMnOpQrStUvWx"
)
results = {"summary": leaked}
with app.app_context():
data = _serialize_results(results).get_json()
assert "sk-proj-AbCdEfGhIjKlMnOpQrStUvWx" not in data["summary"]
assert data["summary"].startswith("Error:")
def test_error_prefixed_formatted_findings_is_scrubbed(self, app):
from local_deep_research.web.api import _serialize_results
leaked = (
"Error: request to "
"https://api.example.com/v1?api_key=sk-LEAK9876543210abcdefgh"
)
results = {"formatted_findings": leaked}
with app.app_context():
data = _serialize_results(results).get_json()
assert "sk-LEAK9876543210abcdefgh" not in data["formatted_findings"]
assert data["formatted_findings"].startswith("Error:")
def test_error_prefixed_finding_content_is_scrubbed(self, app):
from local_deep_research.web.api import _serialize_results
leaked = (
"Error: connection to "
"https://db.example.com:5432/db?password=supersecret123"
)
results = {"findings": [{"phase": "Error", "content": leaked}]}
with app.app_context():
data = _serialize_results(results).get_json()
assert "supersecret123" not in data["findings"][0]["content"]
assert data["findings"][0]["content"].startswith("Error:")
def test_long_non_error_current_knowledge_passes_through_unchanged(
self, app
):
from local_deep_research.web.api import _serialize_results
# The boundary sanitizer only fires on fields starting with
# "Error:". A long, legitimate research summary that is NOT
# exception-derived must pass through untouched -- no truncation
# (the 500-char cap only applies via sanitize_error_for_client,
# which never runs for non-error content) and no credential-regex
# false positives on prose mentions of "Authorization"/"Bearer".
long_summary = (
"Research summary: Authentication systems often use Bearer "
"tokens in the Authorization header, but a well-designed API "
"also supports API keys, OAuth, and session cookies. "
) * 20 # ~1800 chars, well over the boundary cap
assert not long_summary.startswith("Error:")
results = {"current_knowledge": long_summary}
with app.app_context():
data = _serialize_results(results).get_json()
assert data["current_knowledge"] == long_summary
def test_error_field_categorizable_token_survives_boundary_cap(self, app):
from local_deep_research.web.api import _serialize_results
# Regression guard for the strategy cap alignment: an error
# message whose categorizable signal ("Connection refused") sits
# past the 200-char HTTP-client default must survive the boundary
# scrub. If _ERROR_BOUNDARY_MAX_LEN regresses to 200, the token
# is truncated and ErrorReportGenerator classification breaks.
leading = "x" * 230
leaked = f"Error: {leading} Connection refused [Errno 111]"
results = {"current_knowledge": leaked}
with app.app_context():
data = _serialize_results(results).get_json()
assert "Connection refused" in data["current_knowledge"]
assert data["current_knowledge"].startswith("Error:")
def test_scrub_error_fields_helper_covers_all_field_spellings(self, app):
# _scrub_error_fields is the helper called by all three jsonify
# sinks (_serialize_results, generate_report, analyze_documents).
# Verify every scrubbed spelling as a unit: `current_knowledge`
# (raw strategy dicts), `summary` (quick_summary /
# analyze_documents payloads), `formatted_findings`, and
# per-finding `content`. Endpoint-level coverage lives in
# TestQuickSummary / TestAnalyzeDocuments.
from local_deep_research.web.api import _scrub_error_fields
leaked = (
"Error: connect to "
"https://api.example.com/v1?api_key=sk-LEAK1234567890abcd"
)
results = {
"current_knowledge": leaked,
"summary": leaked,
"formatted_findings": leaked,
"findings": [{"phase": "Error", "content": leaked}],
}
_scrub_error_fields(results)
assert "sk-LEAK1234567890abcd" not in results["current_knowledge"]
assert "sk-LEAK1234567890abcd" not in results["summary"]
assert "sk-LEAK1234567890abcd" not in results["formatted_findings"]
assert "sk-LEAK1234567890abcd" not in results["findings"][0]["content"]
assert results["current_knowledge"].startswith("Error:")
assert results["summary"].startswith("Error:")
# ===================================================================
# HTTP method enforcement
# ===================================================================
class TestHttpMethods:
"""Verify endpoints reject wrong HTTP methods."""
def test_health_rejects_post(self, client):
resp = client.post("/api/v1/health")
assert resp.status_code == 405
def test_docs_rejects_post(self, authed_client):
resp = authed_client.post("/api/v1/")
assert resp.status_code == 405
def test_quick_summary_rejects_get(self, authed_client):
resp = authed_client.get("/api/v1/quick_summary")
assert resp.status_code == 405
def test_generate_report_rejects_get(self, authed_client):
resp = authed_client.get("/api/v1/generate_report")
assert resp.status_code == 405
def test_analyze_documents_rejects_get(self, authed_client):
resp = authed_client.get("/api/v1/analyze_documents")
assert resp.status_code == 405