"""Integration tests for the ``full_search.py`` egress Policy Enforcement Point: ``web_search_engines/engines/full_search.py`` — per-URL egress gating in the full-content fetch path: when an ``egress_context`` is present, URLs that ``evaluate_url`` denies for the scope are dropped before any network fetch, allowed ones are kept, and the denial audit log is URL-redacted. These drive the REAL call-site code. Only unavoidable heavy deps are mocked (the network ``batch_fetch_and_extract`` and the SSRF ``validate_url``, so the SSRF axis is isolated from the orthogonal egress-scope axis under test). The egress decision itself (``evaluate_url``) is exercised for real against IP literals, so no DNS / network is required. """ from __future__ import annotations import pytest from loguru import logger from local_deep_research.security.egress.policy import ( EgressContext, EgressScope, ) from local_deep_research.web_search_engines.engines import full_search as fs # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_ctx(scope: EgressScope) -> EgressContext: return EgressContext( scope=scope, primary_engine="library", require_local_llm=False, require_local_embeddings=False, ) class _FakeWebSearch: """Minimal stand-in for the inner web search engine (``.invoke``).""" def __init__(self, results): self._results = results def invoke(self, query): # noqa: D401 - protocol method return list(self._results) # Public + private IP literals resolve locally (no DNS / network) and match # the literal-classification path the existing edge-case tests use. _PUBLIC_URL = "http://93.184.216.34/page" _PRIVATE_URL = "http://10.0.0.5/api" # =========================================================================== # full_search per-URL egress gating # =========================================================================== @pytest.fixture def patched_fetch(monkeypatch): """Capture the URLs that survive the gate and reach the fetcher; isolate the SSRF axis by forcing ``validate_url`` True so only egress scope decides which URLs pass.""" captured = {"urls": None, "calls": 0} def fake_batch(urls, **kwargs): captured["calls"] += 1 captured["urls"] = list(urls) return {u: "content-for-" + u for u in urls} monkeypatch.setattr(fs, "batch_fetch_and_extract", fake_batch) monkeypatch.setattr(fs, "validate_url", lambda u: True) return captured def _engine(results, ctx): return fs.FullSearchResults( llm=None, # llm None -> check_urls returns results unfiltered web_search=_FakeWebSearch(results), egress_context=ctx, ) def test_run_private_only_drops_public_keeps_private(patched_fetch): results = [ {"title": "pub", "link": _PUBLIC_URL}, {"title": "priv", "link": _PRIVATE_URL}, ] engine = _engine(results, _make_ctx(EgressScope.PRIVATE_ONLY)) out = engine.run("q") # Only the private URL reached the fetcher. assert patched_fetch["urls"] == [_PRIVATE_URL] # And only its result carries full content; the denied public one is None. by_link = {r["link"]: r for r in out} assert ( by_link[_PRIVATE_URL]["full_content"] == "content-for-" + _PRIVATE_URL ) assert by_link[_PUBLIC_URL]["full_content"] is None def test_run_public_only_drops_private_keeps_public(patched_fetch): results = [ {"title": "pub", "link": _PUBLIC_URL}, {"title": "priv", "link": _PRIVATE_URL}, ] engine = _engine(results, _make_ctx(EgressScope.PUBLIC_ONLY)) out = engine.run("q") assert patched_fetch["urls"] == [_PUBLIC_URL] by_link = {r["link"]: r for r in out} assert by_link[_PUBLIC_URL]["full_content"] == "content-for-" + _PUBLIC_URL assert by_link[_PRIVATE_URL]["full_content"] is None def test_run_all_denied_skips_fetch_entirely(patched_fetch): """PRIVATE_ONLY with only public URLs -> nothing passes the gate, the network fetcher is never invoked, all results get null content.""" results = [{"title": "pub", "link": _PUBLIC_URL}] engine = _engine(results, _make_ctx(EgressScope.PRIVATE_ONLY)) out = engine.run("q") assert patched_fetch["calls"] == 0 assert out[0]["full_content"] is None def test_run_without_egress_context_does_not_gate(patched_fetch): """Control: with no egress_context the per-URL scope gate is inactive, so both URLs reach the fetcher. This proves the gate above is driven by the egress context and not by some unrelated filter.""" results = [ {"title": "pub", "link": _PUBLIC_URL}, {"title": "priv", "link": _PRIVATE_URL}, ] engine = fs.FullSearchResults( llm=None, web_search=_FakeWebSearch(results), egress_context=None ) engine.run("q") assert patched_fetch["urls"] == [_PUBLIC_URL, _PRIVATE_URL] def test_get_full_content_gates_per_url(patched_fetch): """The secondary ``_get_full_content`` path enforces the same per-URL scope gate.""" items = [ {"title": "pub", "link": _PUBLIC_URL}, {"title": "priv", "link": _PRIVATE_URL}, ] engine = _engine(items, _make_ctx(EgressScope.PRIVATE_ONLY)) out = engine._get_full_content(items) assert patched_fetch["urls"] == [_PRIVATE_URL] by_link = {r["link"]: r for r in out} assert ( by_link[_PRIVATE_URL]["full_content"] == "content-for-" + _PRIVATE_URL ) assert by_link[_PUBLIC_URL]["full_content"] is None def test_denied_url_audit_log_is_redacted(patched_fetch): """The denial audit record must carry only scheme://host (no path / query / token), proving sensitive URL components are not leaked into logs.""" sensitive_url = "http://93.184.216.34/secret/report?token=SUPERSECRET" results = [{"title": "pub", "link": sensitive_url}] records = [] def sink(message): records.append(message.record) # The package disables its own loguru namespace in __init__; enable it so # the in-module audit warnings actually reach our sink. Restore in finally. logger.enable("local_deep_research") sink_id = logger.add(sink, level="WARNING") try: engine = _engine(results, _make_ctx(EgressScope.PRIVATE_ONLY)) engine.run("q") finally: logger.remove(sink_id) logger.disable("local_deep_research") # Find the policy-audit denial record for this URL. audit = [ r for r in records if r["extra"].get("policy_audit") and "url" in r["extra"] ] assert audit, "expected a policy_audit denial record" rec = audit[0] logged_url = rec["extra"]["url"] assert logged_url == "http://93.184.216.34" # The token / path must appear nowhere in the logged URL or message. assert "SUPERSECRET" not in logged_url assert "SUPERSECRET" not in rec["message"] assert "/secret" not in logged_url assert rec["extra"]["reason"] == "scope_mismatch_private_only"