deer-flow/backend/tests/test_webz_tools.py
kongxin cee54cdd49
fix(web-fetch): stop sending .env.example placeholder keys to providers (#8206)
* fix(web-fetch): stop sending .env.example placeholder keys to providers

make config and make setup copy .env.example into .env, and the template
set nine search/fetch provider keys to example values such as
JINA_API_KEY=your-jina-api-key. The default keyless Jina Reader sent
that text as a Bearer key, so every web_fetch on a fresh install failed
with 401 while make doctor reported web fetch as configured.

- .env.example comments those nine lines out, like every other credential
  in the template already is.
- provider_credentials.resolve_api_key reads a your-...-key/-token/-secret
  value as unset, so existing .env files that still hold the placeholders
  recover without editing, for every provider that resolves keys there.

* chore: leave the changelog entry to merge time

Per CONTRIBUTING.md (#8202), maintainers add changelog entries when they
merge a pull request.

* fix(web-search): route every provider key and doctor through one placeholder rule

Review follow-up for #8206:

- provider_credentials exposes is_template_placeholder(), the single
  definition of an .env.example example value. key/token/secret must be
  a whole hyphen-separated word, so a real value such as your-monkey or
  your-passkey is no longer dropped.
- Serper, Serply, Sofya, SpecStream, Unbrowse, Webz, Brave, Tencent WSA
  and You.com resolve config and environment keys through
  resolve_api_key instead of private strip-and-send copies, so an old
  .env that still holds the template value no longer sends it.
- make doctor asks the same predicate: a placeholder key is reported as
  still holding the .env.example value instead of "set", and a literal
  placeholder in config.yaml falls back to the environment key as the
  runtime does.
- A census test keeps credential environment reads under community/
  wrapped in resolve_api_key.

* fix(web-search): match shipped placeholders exactly and mirror Browserless precedence in doctor

Second review follow-up for #8206:

- is_template_placeholder() matches exactly the your-... values that
  .env.example ships instead of a pattern, so a token an operator chose
  (a self-hosted Browserless TOKEN such as your-review-team-token) is
  sent again. A test keeps the set equal to the template.
- make doctor mirrors Browserless: a configured token field is
  authoritative, blank or not, and BROWSERLESS_TOKEN is read only when
  the field is omitted. A blank or placeholder token now warns instead
  of reporting the variable, cross-checked against the client.
- The keyless Jina and You.com providers stay ok but name JINA_API_KEY
  or YDC_API_KEY when a placeholder there is ignored, and a literal
  placeholder api_key in config.yaml is named when no env key backs it.

* fix(doctor): match each web provider's key precedence; drop the dead Jina api_key from docs

Found while re-checking #8206 against every provider's runtime:

- Browserless used as web_fetch had no doctor key check; it now gets the
  same authoritative token handling as web_capture.
- InfoQuest reads only INFOQUEST_API_KEY, so doctor no longer counts an
  api_key field in its tool config as configured.
- GroundRoute needs a key for every call but had no doctor entry, so a
  missing key reported ok.
- You.com names a placeholder api_key in config that it ignores.
- The Jina web_fetch docs snippet carried api_key: $JINA_API_KEY, which
  the tool never reads. With JINA_API_KEY now commented out in
  .env.example, that line made config loading fail on a fresh install.
- The provider_credentials docstring now scopes doctor's use of the
  predicate to web provider keys, and the scripts guide no longer says
  .env.example ships live placeholder keys.
2026-11-13 23:23:29 +08:00

278 lines
12 KiB
Python

"""Offline Webz request/response contract using the real httpx serializer."""
import asyncio
import json
from datetime import UTC, datetime
from types import SimpleNamespace
import httpx
import pytest
import yaml
from httpx import AsyncClient as RealAsyncClient
@pytest.fixture
def webz(monkeypatch):
import deerflow.community.webz.tools as module
options = {"api_key": "synthetic-key"}
monkeypatch.setattr(module, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra=options)))
monkeypatch.delenv("WEBZ_API_KEY", raising=False)
requests = []
response = {"results": []}
status = [200]
client_class = httpx.AsyncClient
async def handler(request):
requests.append(request)
return httpx.Response(status[0], json=response)
monkeypatch.setattr(module.httpx, "AsyncClient", lambda **kwargs: client_class(transport=httpx.MockTransport(handler), **kwargs))
return module, options, requests, response, status
@pytest.mark.asyncio
async def test_request_filters_and_normalized_results(webz, monkeypatch):
module, _, requests, response, _ = webz
monkeypatch.setenv("WEBZ_API_KEY", "unused-environment-key")
response["results"] = [
{
"score": 8.5,
"article": {"title": "News", "url": "https://example.com/news", "published_at": "2026-09-20T12:00:00Z", "summary": "Summary"},
"chunk": {"text": "Matching passage"},
"metadata": {"domain": "example.com", "language": "english", "country": "US"},
}
]
result = json.loads(
await module.web_search_tool.ainvoke(
{
"query": "renewable energy",
"max_results": 3,
"published_from": "2026-09-01",
"published_to": "2026-09-20",
"language": ["english"],
"country": ["US"],
"source": ["example.com"],
"sentiment": ["positive"],
"category": ["Environment"],
}
)
)
request = requests[0]
assert str(request.url) == "https://api.webz.io/api/news/context"
assert request.headers["authorization"] == "Bearer synthetic-key"
assert json.loads(request.content) == {
"query": "renewable energy",
"k": 3,
"filters": {"published_from": "2026-09-01", "published_to": "2026-09-20", "language": ["english"], "country": ["US"], "domain": ["example.com"], "sentiment": ["positive"], "category": ["Environment"]},
}
assert result == {
"query": "renewable energy",
"returned_results": 1,
"results": [{"title": "News", "url": "https://example.com/news", "content": "Matching passage", "published_at": "2026-09-20T12:00:00Z", "source": {"domain": "example.com", "language": "english", "country": "US"}}],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(("window", "start"), [("day", "2026-09-23"), ("week", "2026-09-17"), ("month", "2026-08-25"), ("year", "2025-09-24")])
async def test_relative_window_uses_utc_and_explicit_lower_bound_wins(webz, monkeypatch, window, start):
module, _, requests, _, _ = webz
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 9, 24, 12, tzinfo=UTC)
monkeypatch.setattr(module, "datetime", Clock)
await module.web_search_tool.ainvoke({"query": "news", "time_range": window})
assert json.loads(requests[-1].content)["filters"] == {"published_from": start}
await module.web_search_tool.ainvoke({"query": "news", "time_range": window, "published_from": "2026-09-22"})
assert json.loads(requests[-1].content)["filters"] == {"published_from": "2026-09-22"}
@pytest.mark.asyncio
async def test_environment_key_configured_count_and_no_implicit_filters(webz, monkeypatch):
module, options, requests, _, _ = webz
options.update(api_key=" ", max_results="1000")
monkeypatch.setenv("WEBZ_API_KEY", " env-key ")
assert json.loads(await module.web_search_tool.ainvoke({"query": " news "})) == {"query": "news", "returned_results": 0, "results": []}
assert requests[0].headers["authorization"] == "Bearer env-key"
assert json.loads(requests[0].content) == {"query": "news", "k": 100}
@pytest.mark.asyncio
async def test_missing_key_never_makes_request(webz):
module, options, requests, _, _ = webz
options.clear()
assert "WEBZ_API_KEY" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))["error"]
assert not requests
@pytest.mark.asyncio
async def test_env_template_placeholder_is_not_sent_as_a_key(webz, monkeypatch):
"""An ``.env`` made from the old template still holds ``WEBZ_API_KEY=your-webz-api-key``."""
module, options, requests, _, _ = webz
options.update(api_key="your-webz-api-key")
monkeypatch.setenv("WEBZ_API_KEY", "your-webz-api-key")
assert "WEBZ_API_KEY" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))["error"]
assert not requests
monkeypatch.setenv("WEBZ_API_KEY", "env-key")
await module.web_search_tool.ainvoke({"query": "news"})
assert requests[0].headers["authorization"] == "Bearer env-key"
@pytest.mark.asyncio
@pytest.mark.parametrize("arguments", [{"query": " "}, {"query": "x" * 751}, {"query": "word " * 101}, {"query": "news", "published_from": "yesterday"}, {"query": "news", "published_from": "2026-09-20", "published_to": "2026-09-01"}])
async def test_invalid_input_never_makes_request(webz, arguments):
module, _, requests, _, _ = webz
assert "error" in json.loads(await module.web_search_tool.ainvoke(arguments))
assert not requests
@pytest.mark.asyncio
@pytest.mark.parametrize("status_code", [302, 401, 429, 500])
async def test_http_errors_do_not_echo_provider_body_or_credentials(webz, status_code, caplog):
module, _, requests, response, status = webz
response.update(error="synthetic-key")
status[0] = status_code
result = await module.web_search_tool.ainvoke({"query": "news"})
assert str(status_code) in json.loads(result)["error"]
assert "synthetic-key" not in result + caplog.text
assert len(requests) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [{}, {"results": None}, {"results": [None]}, {"results": [{"article": []}]}])
async def test_malformed_payload_is_an_error(webz, payload, caplog):
module, _, _, response, _ = webz
response.clear()
response.update(payload)
assert "error" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))
assert "Webz returned an unexpected response" in caplog.text
@pytest.mark.asyncio
async def test_cancellation_propagates(webz, monkeypatch):
module, _, _, _, _ = webz
async def cancelled(*args, **kwargs):
raise asyncio.CancelledError
monkeypatch.setattr(RealAsyncClient, "post", cancelled)
with pytest.raises(asyncio.CancelledError):
await module.web_search_tool.ainvoke({"query": "news"})
def test_setup_wizard_offers_webz_with_env_key():
from wizard.providers import SEARCH_PROVIDERS
from wizard.writer import build_minimal_config
provider = next(p for p in SEARCH_PROVIDERS if p.name == "webz")
assert provider.env_var == "WEBZ_API_KEY"
config = build_minimal_config(provider_use="langchain_openai:ChatOpenAI", model_name="dummy", display_name="Dummy", api_key_field="api_key", env_var="OPENAI_API_KEY", search_use=provider.use, search_extra_config=provider.extra_config)
assert next(t for t in yaml.safe_load(config)["tools"] if t["name"] == "web_search")["use"] == "deerflow.community.webz.tools:web_search_tool"
@pytest.mark.asyncio
@pytest.mark.parametrize("exception", [httpx.ReadTimeout("synthetic-key"), httpx.ConnectError("synthetic-key")])
async def test_transport_failures_are_sanitized(webz, monkeypatch, exception):
module, _, _, _, _ = webz
async def fail(*args, **kwargs):
raise exception
monkeypatch.setattr(RealAsyncClient, "post", fail)
result = await module.web_search_tool.ainvoke({"query": "news"})
assert "error" in json.loads(result)
assert "synthetic-key" not in result
@pytest.mark.asyncio
async def test_summary_fallback_and_output_limit(webz):
module, _, _, response, _ = webz
response["results"] = [{"article": {"title": "News", "url": "https://example.com", "summary": "Summary"}}] * 3
response["total_results"] = 3
result = json.loads(await module.web_search_tool.ainvoke({"query": "news", "max_results": 1}))
assert result["returned_results"] == len(result["results"]) == 1
assert "total_results" not in result
assert result["results"][0]["content"] == "Summary"
@pytest.mark.asyncio
@pytest.mark.parametrize("value, expected", [(None, 5), (True, 5), (False, 5), ("invalid", 5), (3.5, 5), (-3.5, 5), (float("inf"), 5), (float("nan"), 5), (3.0, 3), (-1, 1), ("12", 12), (200, 100)])
async def test_configured_result_count(webz, value, expected):
module, options, requests, _, _ = webz
options["max_results"] = value
await module.web_search_tool.ainvoke({"query": "news"})
assert json.loads(requests[0].content)["k"] == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("configured, arguments, expected", [(5, {"max_results": 20}, 20), (20, {"max_results": 5}, 5), ("invalid", {"max_results": 3}, 3), (20, {}, 20), (20, {"max_results": None}, 20)])
async def test_call_count_overrides_configuration_unless_omitted(webz, configured, arguments, expected):
module, options, requests, _, _ = webz
options["max_results"] = configured
await module.web_search_tool.ainvoke({"query": "news", **arguments})
assert json.loads(requests[0].content)["k"] == expected
@pytest.mark.asyncio
async def test_missing_configured_count_defaults_to_five(webz):
module, _, requests, _, _ = webz
await module.web_search_tool.ainvoke({"query": "news"})
assert json.loads(requests[0].content)["k"] == 5
def test_doctor_recognizes_webz_credentials(tmp_path, monkeypatch):
import doctor
config = tmp_path / "config.yaml"
config.write_text("tools:\n - name: web_search\n use: deerflow.community.webz.tools:web_search_tool\n", encoding="utf-8")
monkeypatch.delenv("WEBZ_API_KEY", raising=False)
missing = doctor.check_web_search(config)
assert missing.status == "warn"
assert "WEBZ_API_KEY" in missing.detail
monkeypatch.setenv("WEBZ_API_KEY", "synthetic-key")
present = doctor.check_web_search(config)
assert present.status == "ok"
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_item", [None, {"article": []}, {"article": {}, "chunk": None}, {"article": {}, "metadata": "synthetic-key"}, {"article": {"title": ["synthetic-key"]}}])
async def test_malformed_item_preserves_valid_neighbors(webz, bad_item, caplog):
module, _, _, response, _ = webz
response["results"] = [
{"article": {"title": "First", "summary": "First summary"}},
bad_item,
{"article": {"title": "Last", "summary": "Last summary"}},
]
result = json.loads(await module.web_search_tool.ainvoke({"query": "news", "max_results": 3}))
assert result["returned_results"] == 2
assert [item["title"] for item in result["results"]] == ["First", "Last"]
assert "Skipping malformed Webz result at index 1" in caplog.text
assert "synthetic-key" not in caplog.text
@pytest.mark.asyncio
async def test_invalid_json_is_logged_without_response_body(webz, monkeypatch, caplog):
module, _, _, _, _ = webz
async def invalid_json(*args, **kwargs):
return httpx.Response(200, text="synthetic-key", request=httpx.Request("POST", "https://api.webz.io/api/news/context"))
monkeypatch.setattr(RealAsyncClient, "post", invalid_json)
result = await module.web_search_tool.ainvoke({"query": "news"})
assert "unexpected response" in json.loads(result)["error"]
assert "Webz returned an unexpected response" in caplog.text
assert "synthetic-key" not in result + caplog.text
@pytest.mark.asyncio
async def test_invalid_configured_count_logs_value(webz, caplog):
module, options, requests, _, _ = webz
options["max_results"] = 3.5
await module.web_search_tool.ainvoke({"query": "news"})
assert json.loads(requests[0].content)["k"] == 5
assert "max_results=3.5" in caplog.text