deer-flow/backend/tests/test_image_result_url_safety.py
treasuresyq 6caa5addf3
feat(community): migrate You.com tools to httpx.AsyncClient (#7186)
* feat(community): migrate You.com tools to async HTTP

The You.com provider sent its search request through a sync httpx.Client. A
sync LangChain tool invoked through ainvoke runs on the default executor, so
the request held one of that pool's threads for the whole network round-trip,
and several providers queried in parallel queued behind each other.

Make the tool a coroutine over httpx.AsyncClient, resolve the API key and
the configured max_results in a single config read, and move both disk reads
off the event loop: the tool config, and the User-Agent, which reads the
installed package metadata the first time it is needed.

* refactor(community): drop the dead You.com API key wrapper

The async migration resolved the key and max_results through
_resolve_tool_settings, which left _get_api_key with no production
caller: only TestGetApiKey still exercised it. Keeping a second seam
around the same config read lets it drift from the real call path, so
drop the wrapper and point the tests at _resolve_tool_settings.

Also keep the hot path off the executor: _user_agent is cached, so only
the first call reads the installed package metadata. Later calls are
in-memory hits and now skip the to_thread round-trip.

Finally, record which httpx client factory the provider reaches for in
the runtime anchor. MockTransport runs its handler on the calling thread
without real blocking I/O, so a provider that stayed async but sent the
request through a sync client would satisfy the thread assertion; the
recorded factory is what catches that partial regression.

* test(community): align You.com URL safety stub with async tool

The URL safety matrix still patched the removed _get_api_key seam and invoked the You.com tool synchronously. Mock the awaited search with AsyncMock and call the tool through ainvoke so URL screening remains covered after the async migration.

---------

Co-authored-by: treasuresyq <309743290+treasuresyq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 17:59:14 +08:00

218 lines
8.3 KiB
Python

"""The image and text-search providers enforce the same string-only result URL policy."""
import importlib
import inspect
import json
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
@pytest.fixture(params=["url_safety", "brave.tools", "serper.tools", "infoquest.infoquest_client"])
def screen(request):
module = importlib.import_module(f"deerflow.community.{request.param}")
return module.safe_public_result_url if request.param == "url_safety" else module._safe_public_url
@pytest.mark.parametrize(
"url",
[
"http://metadata.google.internal/image",
"http://[64:ff9b::7f00:1]/image",
"http://[::ffff:127.0.0.1]/image",
"http://[2002:7f00:1::]/image",
"http://[::10.0.0.1]/image",
"http://224.0.0.1/image",
"http://3758096385/image",
"http://[ff02::1]/image",
"http://[2002:e000:1::]/image",
"http://240.0.0.1/image",
"http://127.0.0.1/image",
"http://0x7f000001/image",
"http://0177.0.0.1/image",
"http://localhost./image",
"file:///etc/passwd",
],
)
def test_image_providers_reject_non_public_results(screen, url):
assert screen(url) == ""
@pytest.mark.parametrize(
"url",
["https://images.example/image", "http://8.8.8.8/image", "http://134744072/image", "http://[2606:4700:4700::1111]/image"],
)
def test_image_providers_keep_public_results_without_dns(screen, url):
with patch("socket.getaddrinfo", side_effect=AssertionError("Result screening must not perform DNS")):
assert screen(url) == url
_METADATA_URL = "http://169.254.169.254/latest/meta-data/"
_PUBLIC_URL = "https://example.com/report"
def _sync(tool):
return lambda: tool.invoke({"query": "q"})
def _async(tool):
async def run():
return await tool.ainvoke({"query": "q"})
return run
def _stub_tavily(module, url):
client = MagicMock()
client.search = AsyncMock(return_value={"results": [{"title": "t", "url": url, "content": "c"}]})
client.close = AsyncMock()
return [patch.object(module, "_get_tavily_client", return_value=client)], _async(module.web_search_tool)
def _stub_exa(module, url):
client = MagicMock()
client.search.return_value = SimpleNamespace(results=[SimpleNamespace(title="t", url=url, highlights=["c"])])
return [patch.object(module, "_get_exa_client", return_value=client)], _sync(module.web_search_tool)
def _stub_youcom(module, url):
data = {"results": {"web": [{"title": "t", "url": url, "description": "c"}]}}
return [patch.object(module, "_search", new=AsyncMock(return_value=data))], _async(module.web_search_tool)
def _stub_tencent_wsa(module, url):
data = {"Response": {"Pages": [json.dumps({"title": "t", "url": url, "content": "c"})]}}
return [patch.object(module, "_get_api_key", return_value="k"), patch.object(module, "_search", return_value=(data, None))], _sync(module.web_search_tool)
def _stub_serply(module, url):
data = {"results": [{"title": "t", "link": url, "description": "c"}]}
return [patch.object(module, "_get_api_key", return_value="k"), patch.object(module, "_serply_get", return_value=(data, None))], _sync(module.web_search_tool)
def _stub_groundroute(module, url):
data = {"results": [{"title": "t", "url": url, "snippet": "c"}]}
config = SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra={"api_key": "synthetic-test-key"}))
transport = httpx.MockTransport(lambda request: httpx.Response(200, json=data))
real_client = httpx.AsyncClient
return [
patch.object(module, "get_app_config", return_value=config),
patch.object(
module,
"httpx",
SimpleNamespace(AsyncClient=lambda **kwargs: real_client(transport=transport, **kwargs), HTTPStatusError=httpx.HTTPStatusError),
),
], _async(module.web_search_tool)
def _stub_sofya(module, url):
data = {"results": [{"title": "t", "url": url, "content": "c"}]}
return [patch.object(module, "_get_api_key", return_value="k"), patch.object(module, "_sofya_post", return_value=(data, None))], _sync(module.web_search_tool)
def _stub_ddg_search(module, url):
rows = [{"title": "t", "href": url, "body": "c"}]
return [patch.object(module, "_search_text", return_value=rows)], _sync(module.web_search_tool)
def _sdk_search_result(url):
return SimpleNamespace(web=[SimpleNamespace(title="t", url=url, description="c")])
def _stub_fastcrw(module, url):
client = MagicMock()
client.search.return_value = _sdk_search_result(url)
return [patch.object(module, "_get_fastcrw_client", return_value=client)], _sync(module.web_search_tool)
def _stub_firecrawl(module, url):
client = MagicMock()
client.search = AsyncMock(return_value=_sdk_search_result(url))
client.close = AsyncMock()
return [patch.object(module, "_get_firecrawl_client", return_value=client)], _async(module.web_search_tool)
def _stub_searxng(module, url):
client = MagicMock()
client.search = AsyncMock(return_value=[{"title": "t", "url": url, "content": "c"}])
return [patch.object(module, "_get_searxng_client", return_value=client)], _async(module.web_search_tool)
def _stub_brave(module, url):
data = {"web": {"results": [{"title": "t", "url": url, "description": "c"}]}}
return [patch.object(module, "_get_api_key", return_value="k"), patch.object(module, "_brave_get", return_value=(data, None))], _sync(module.web_search_tool)
def _stub_serper(module, url):
data = {"organic": [{"title": "t", "link": url, "snippet": "c"}]}
return [patch.object(module, "_get_api_key", return_value="k"), patch.object(module, "_serper_post", return_value=(data, None))], _sync(module.web_search_tool)
def _stub_webz(module, url):
data = {"results": [{"article": {"title": "t", "url": url, "summary": "c", "published_at": ""}}]}
transport = httpx.MockTransport(lambda request: httpx.Response(200, json=data))
real_client = httpx.AsyncClient
return [
patch.object(module, "_options", return_value={"api_key": "k"}),
patch.object(module.httpx, "AsyncClient", lambda **kwargs: real_client(transport=transport, **kwargs)),
], _async(module.web_search_tool)
_TEXT_SEARCH_STUBS = {
"tavily": _stub_tavily,
"exa": _stub_exa,
"youcom": _stub_youcom,
"tencent_wsa": _stub_tencent_wsa,
"serply": _stub_serply,
"groundroute": _stub_groundroute,
"sofya": _stub_sofya,
"ddg_search": _stub_ddg_search,
"fastcrw": _stub_fastcrw,
"firecrawl": _stub_firecrawl,
"searxng": _stub_searxng,
"brave": _stub_brave,
"serper": _stub_serper,
"webz": _stub_webz,
}
@pytest.mark.anyio
@pytest.mark.parametrize("provider", sorted(_TEXT_SEARCH_STUBS))
@pytest.mark.parametrize(
("url", "expected"),
[(_METADATA_URL, ""), ("http://127.0.0.1:8080/admin", ""), (_PUBLIC_URL, _PUBLIC_URL)],
)
async def test_text_search_providers_screen_result_urls(provider, url, expected):
"""web_search must not hand a non-public provider URL to the model (#7036).
Only the provider payload varies; the title and snippet stay in the row.
"""
module = importlib.import_module(f"deerflow.community.{provider}.tools")
patches, run = _TEXT_SEARCH_STUBS[provider](module, url)
with ExitStack() as stack:
config = stack.enter_context(patch.object(module, "get_app_config"))
config.return_value.get_tool_config.return_value = None
for p in patches:
stack.enter_context(p)
raw = run()
if inspect.isawaitable(raw):
raw = await raw
payload = json.loads(raw)
# Some providers return a bare list, others a {"results": [...]} envelope.
rows = payload["results"] if isinstance(payload, dict) else payload
assert [row["url"] for row in rows] == [expected]
assert rows[0]["title"] == "t"
# The snippet key differs by provider ("content" or "snippet").
assert "c" in (rows[0].get("content"), rows[0].get("snippet"))
@pytest.mark.parametrize(("url", "expected"), [(_METADATA_URL, ""), (_PUBLIC_URL, _PUBLIC_URL)])
def test_serply_scholar_screens_pdf_url(url, expected):
from deerflow.community.serply.tools import _normalize_row
row = _normalize_row("scholar", {"title": "t", "link": url, "doc": {"link": url}})
assert (row["url"], row["pdf_url"]) == (expected, expected)