Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

375 lines
13 KiB
Python

"""Unit tests for ``advanced_search_system.tools.fetch.build_fetch_tool``.
Pins the mode dispatch and the prompt-content contract for the two
summary variants (focus-only vs focus + overall query). Avoids any real
HTTP — patches ``ContentFetcher`` and the model.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import (
SearchResultsCollector,
)
from local_deep_research.advanced_search_system.tools.fetch import (
FETCH_MODES,
build_fetch_tool,
)
def _fetcher_cm(*, status="success", title="Page", content="Body text"):
fetcher = MagicMock()
fetcher.fetch.return_value = {
"status": status,
"title": title,
"content": content,
}
cm = MagicMock()
cm.__enter__.return_value = fetcher
cm.__exit__.return_value = False
return cm
def _model_returning(text: str):
"""A model whose ``invoke`` returns an object with ``.content == text``."""
msg = MagicMock()
msg.content = text
model = MagicMock()
model.invoke.return_value = msg
return model
def test_fetch_modes_constant_lists_all_supported_values():
assert FETCH_MODES == (
"disabled",
"full",
"summary_focus",
"summary_focus_query",
)
def test_disabled_mode_returns_none():
assert build_fetch_tool("disabled", SearchResultsCollector([])) is None
def test_full_mode_returns_tool_with_url_only_signature():
tool = build_fetch_tool("full", SearchResultsCollector([]))
assert tool is not None
assert "url" in tool.args
assert "focus" not in tool.args
def test_summary_focus_requires_model():
with pytest.raises(ValueError, match="summary_focus"):
build_fetch_tool("summary_focus", SearchResultsCollector([]))
def test_summary_focus_query_requires_model():
with pytest.raises(ValueError, match="summary_focus_query"):
build_fetch_tool("summary_focus_query", SearchResultsCollector([]))
def test_summary_focus_tool_calls_model_with_focus_only_prompt():
collector = SearchResultsCollector([])
model = _model_returning("relevant fact 1")
tool = build_fetch_tool(
"summary_focus",
collector,
model=model,
overall_query="should not appear",
)
assert tool is not None
assert "focus" in tool.args
cm = _fetcher_cm(title="T", content="page body")
with patch(
"local_deep_research.content_fetcher.ContentFetcher", return_value=cm
):
out = tool.invoke({"url": "http://example.com/", "focus": "year of X"})
model.invoke.assert_called_once()
prompt = model.invoke.call_args[0][0]
# focus-only mode must NOT mention the overall query, even if one was passed
assert "Why this page was fetched: year of X" in prompt
assert "Overall research question" not in prompt
# Output is prefixed with citation index so the agent can cite consistently
assert out.startswith("[1] ")
assert "relevant fact 1" in out
def test_summary_focus_query_includes_overall_query_in_prompt():
collector = SearchResultsCollector([])
model = _model_returning("relevant fact 2")
tool = build_fetch_tool(
"summary_focus_query",
collector,
model=model,
overall_query="When did Liepmann receive the Prandtl-Ring Award?",
)
cm = _fetcher_cm(title="T", content="page body")
with patch(
"local_deep_research.content_fetcher.ContentFetcher", return_value=cm
):
tool.invoke({"url": "http://example.com/", "focus": "year"})
prompt = model.invoke.call_args[0][0]
assert (
"Overall research question: When did Liepmann receive the Prandtl-Ring Award?"
in prompt
)
assert "Why this page was fetched: year" in prompt
def test_summary_focus_query_with_empty_overall_query_falls_back_to_focus_only():
"""An empty overall_query should not produce a stale 'Overall research question:' line."""
collector = SearchResultsCollector([])
model = _model_returning("ok")
tool = build_fetch_tool(
"summary_focus_query",
collector,
model=model,
overall_query="", # empty
)
cm = _fetcher_cm()
with patch(
"local_deep_research.content_fetcher.ContentFetcher", return_value=cm
):
tool.invoke({"url": "http://example.com/", "focus": "x"})
prompt = model.invoke.call_args[0][0]
assert "Overall research question" not in prompt
def test_unknown_mode_raises_with_valid_modes_listed():
with pytest.raises(ValueError, match="Unknown fetch mode"):
build_fetch_tool("magic", SearchResultsCollector([]))
# ---- issue #3826: web.enable_javascript_rendering plumbing ----
def _captured_content_fetcher_kwargs(invoke_target):
"""Run *invoke_target* with ContentFetcher patched and return its
constructor kwargs from the call.
The patched factory returns a context manager whose ``fetch`` succeeds
so the tool body runs end-to-end up to the call we care about.
"""
cm = _fetcher_cm()
with patch(
"local_deep_research.content_fetcher.ContentFetcher", return_value=cm
) as factory:
invoke_target()
assert factory.call_args is not None
return factory.call_args.kwargs
def test_full_mode_passes_js_off_when_snapshot_disables_it():
"""Full-mode fetch tool must pass enable_js_rendering=False to
ContentFetcher when the snapshot disables JS rendering."""
collector = SearchResultsCollector([])
snapshot = {
"web.enable_javascript_rendering": {
"value": False,
"ui_element": "checkbox",
}
}
tool = build_fetch_tool("full", collector, settings_snapshot=snapshot)
kwargs = _captured_content_fetcher_kwargs(
lambda: tool.invoke({"url": "http://example.com/"})
)
assert kwargs.get("enable_js_rendering") is False
def test_full_mode_passes_js_on_when_snapshot_enables_it():
"""When the snapshot opts in, JS rendering is forwarded to ContentFetcher."""
collector = SearchResultsCollector([])
snapshot = {
"web.enable_javascript_rendering": {
"value": True,
"ui_element": "checkbox",
}
}
tool = build_fetch_tool("full", collector, settings_snapshot=snapshot)
kwargs = _captured_content_fetcher_kwargs(
lambda: tool.invoke({"url": "http://example.com/"})
)
assert kwargs.get("enable_js_rendering") is True
def test_full_mode_defaults_to_js_off_without_snapshot():
"""No snapshot, no thread-local context → JS disabled (safe default)."""
collector = SearchResultsCollector([])
tool = build_fetch_tool("full", collector)
kwargs = _captured_content_fetcher_kwargs(
lambda: tool.invoke({"url": "http://example.com/"})
)
assert kwargs.get("enable_js_rendering") is False
def test_fetch_error_scrubs_credentialed_url_from_exception():
"""A fetch exception that embeds a credentialed URL must be scrubbed
before the tool return reaches the agent/LLM and the user-visible
research output (credential-leak follow-up to #4625). Full detail stays
in the server log."""
collector = SearchResultsCollector([])
tool = build_fetch_tool("full", collector)
fetcher = MagicMock()
fetcher.fetch.side_effect = Exception(
"ConnectionError for https://user:SUPERSECRET123@proxy.example.com:8080"
)
cm = MagicMock()
cm.__enter__.return_value = fetcher
cm.__exit__.return_value = False
with patch(
"local_deep_research.content_fetcher.ContentFetcher", return_value=cm
):
out = tool.invoke({"url": "http://example.com/"})
assert "SUPERSECRET123" not in out # credential scrubbed
assert "Error fetching" in out # still a useful error for the agent
def test_failed_fetch_scrubs_credentials_from_result_error():
"""A non-success fetch result carries ContentFetcher's raw error string,
which can embed a credentialed URL. It must be scrubbed before the
'Failed to fetch' tool return reaches the agent/LLM (#4633)."""
collector = SearchResultsCollector([])
tool = build_fetch_tool("full", collector)
fetcher = MagicMock()
fetcher.fetch.return_value = {
"status": "error",
"error": "blocked: https://user:SUPERSECRET999@cdn.example.com",
}
cm = MagicMock()
cm.__enter__.return_value = fetcher
cm.__exit__.return_value = False
with patch(
"local_deep_research.content_fetcher.ContentFetcher", return_value=cm
):
out = tool.invoke({"url": "http://example.com/"})
assert "SUPERSECRET999" not in out # credential scrubbed
assert "Failed to fetch" in out # still a useful error for the agent
def test_summary_mode_forwards_js_setting():
"""Summary-mode tool also forwards the JS toggle from the snapshot."""
collector = SearchResultsCollector([])
model = _model_returning("ok")
snapshot = {
"web.enable_javascript_rendering": {
"value": False,
"ui_element": "checkbox",
}
}
tool = build_fetch_tool(
"summary_focus",
collector,
model=model,
settings_snapshot=snapshot,
)
kwargs = _captured_content_fetcher_kwargs(
lambda: tool.invoke({"url": "http://example.com/", "focus": "x"})
)
assert kwargs.get("enable_js_rendering") is False
# ---------------------------------------------------------------------------
# Egress policy: an out-of-scope URL is a RECOVERABLE tool message, not a
# raised exception. Re-raising used to abort a pooled subagent (and depended
# on each agent's tool-error layer); returning a message lets the lead agent
# and subagents handle it identically and stay in-scope. The URL is never
# fetched either way — only the reporting changes.
# ---------------------------------------------------------------------------
def test_full_mode_egress_denial_returns_message_not_raises():
from local_deep_research.security.egress.policy import Decision
collector = SearchResultsCollector([])
tool = build_fetch_tool("full", collector, egress_context=object())
with patch(
"local_deep_research.security.egress.policy.evaluate_url",
return_value=Decision(False, "scope_mismatch_private_only"),
):
out = tool.invoke({"url": "https://public.example.com/x"})
assert isinstance(out, str)
assert "Cannot fetch" in out
assert "scope_mismatch_private_only" in out # the reason is surfaced
assert "skip external URLs" in out # the adapt-and-stay-local hint
# The denied URL must never be registered as a citation.
assert collector.results == []
def test_summary_mode_egress_denial_returns_message_not_raises():
from local_deep_research.security.egress.policy import Decision
collector = SearchResultsCollector([])
model = _model_returning("should-never-be-called")
tool = build_fetch_tool(
"summary_focus", collector, model=model, egress_context=object()
)
with patch(
"local_deep_research.security.egress.policy.evaluate_url",
return_value=Decision(False, "scope_mismatch_private_only"),
):
out = tool.invoke({"url": "https://public.example.com/x", "focus": "y"})
assert isinstance(out, str)
assert "Cannot fetch" in out
assert "scope_mismatch_private_only" in out
# The gate fires BEFORE any fetch/summarise work.
model.invoke.assert_not_called()
assert collector.results == []
def test_fetch_error_log_redacts_url_and_adds_mode():
"""The server-log line for a fetch error carries the MODE and a REDACTED
scheme://host (no userinfo / path / query) — enough to locate the failure
without leaking credentials, query tokens, page paths, or content. Asserted
on the args passed to logger.exception (robust to loguru sink config)."""
collector = SearchResultsCollector([])
tool = build_fetch_tool("full", collector)
fetcher = MagicMock()
fetcher.fetch.side_effect = Exception("boom")
cm = MagicMock()
cm.__enter__.return_value = fetcher
cm.__exit__.return_value = False
url = (
"https://user:SECRET123@proxy.example.com:8080/secretpath?token=ABCXYZ"
)
with patch(
"local_deep_research.advanced_search_system.tools.fetch.logger"
) as mock_logger:
with patch(
"local_deep_research.content_fetcher.ContentFetcher",
return_value=cm,
):
tool.invoke({"url": url})
mock_logger.exception.assert_called_once()
args = mock_logger.exception.call_args.args
# args = (template, mode_label, redacted_url)
assert "mode={}" in args[0] and "url={}" in args[0]
assert "full" in args # the mode
assert (
"https://proxy.example.com:8080" in args
) # redacted scheme://host:port
flat = " ".join(map(str, args))
assert "SECRET123" not in flat # userinfo dropped
assert "ABCXYZ" not in flat # query token dropped
assert "secretpath" not in flat # path dropped