"""Tests that LLM provider error paths never leak API key bytes into logs
or exception messages reaching callers.

Bundled with this file: a production fix to
``src/local_deep_research/llm/providers/implementations/google.py`` whose
``list_models_for_api`` method previously used ``logger.exception(...)``
to log a ``requests`` exception whose message embedded the full request
URL — and Google's API requires the API key as a ``?key=...`` query
parameter, so the key value was written to every loguru sink.

The tests below pin no-leak behavior across a few representative
providers, with the Google case being the one that previously failed.
"""

import base64
from unittest.mock import Mock, patch
from urllib.parse import quote, quote_plus

import pytest
import requests

# A recognizable sentinel that should never appear in any logged or
# returned text after these tests run. Kept URL-safe so it can be
# embedded literally in test URLs without confusing `urllib.parse`; the
# encoding-matrix helper covers non-URL transformations (base64, repr,
# truncation) that *do* change shape.
_LEAKED_KEY = "sk-leaked-sentinel-DO-NOT-APPEAR-12345"
# An opaque key with NO regex-detectable shape (no sk-/pk- prefix, not a URL
# param, not a Bearer token). Only the literal-redaction pass (self.api_key via
# _secret_attrs) can scrub it — used to prove the literal net for header keys.
_LEAKED_OPAQUE_KEY = "opaqueleakedsentinelDONOTAPPEAR0123456789"


def _all_encodings_of(secret: str) -> list:
    """Return every encoding of *secret* a leak might appear under.

    Used by the leak tests to assert no form of the sentinel reaches the
    log output. New providers / engines that introduce different encoding
    paths (e.g., a SDK that wraps the key in a JWT) should extend this
    helper so the contract scales.
    """
    return [
        secret,
        quote(secret, safe=""),  # %-encoded for ?key=
        quote_plus(secret),  # +-encoded for form-urlencoded
        repr(secret)[1:-1],  # f-string {x!r} leak shape
        base64.b64encode(secret.encode()).decode(),
        secret[:8],  # partial-leak (mask formatters)
    ]


def _stub_rate_tracker() -> Mock:
    """Return a minimal rate_tracker stub for tests that exercise the
    request-layer catch block but not rate limiting.

    Bypasses rate limiting without pulling in the real
    ``AdaptiveRateLimitTracker`` (which would trigger DB imports and a
    settings snapshot). The leak tests want the catch block to run, not
    the rate-limit code path.

    - ``apply_rate_limit`` returns ``0`` so the engine's
      ``time.sleep(self._last_wait_time)`` is a no-op (keeps the test
      fast). Forgetting this would have ``Mock`` return a truthy
      ``Mock`` instance and either sleep garbage or raise.
    - ``enabled = False`` so any code path that gates on
      ``self.rate_tracker.enabled`` sees the stub as disabled.
    - ``record_event`` is a no-op (the real implementation writes to
      a DB; the stub has no DB to write to).
    """
    tracker = Mock()
    tracker.enabled = False
    tracker.apply_rate_limit.return_value = 0
    return tracker


@pytest.fixture
def google_provider_module():
    """Late-import the Google provider so the test's loguru_caplog
    fixture has a chance to enable propagation first.
    """
    from local_deep_research.llm.providers.implementations import google

    return google


class TestGoogleProviderKeyLeakage:
    """The Google provider's ``list_models_for_api`` previously built a
    URL with the API key as a query parameter (Google's then-documented
    requirement). If the upstream request raised with the URL in its
    exception message, ``logger.exception`` would write the key to logs
    verbatim. The fix at
    :file:`src/local_deep_research/llm/providers/implementations/google.py`
    is two-layered:

    1. **Prevention by construction (primary defense, issue #4184):** the
       key is now passed via the ``x-goog-api-key`` header, so the URL
       handed to ``requests`` no longer carries the secret. HTTP
       exception messages embed the URL but never the headers.
    2. **Log-side redaction (defense-in-depth):** the except handler
       still wraps ``str(e)`` with ``redact_secrets(..., api_key)`` and
       uses ``logger.warning`` (no traceback) so any future code path
       that reintroduces the key into a logged string is still scrubbed.

    Both properties are pinned below — the construction-layer test
    fails if a maintainer ever reverts to ``?key=...``; the log-side
    tests fail if the redaction wrap is removed.
    """

    def test_api_key_is_passed_via_header_not_url(self, google_provider_module):
        """Prevention-by-construction: the URL handed to ``safe_get`` must
        not contain the api_key under any name, and the ``x-goog-api-key``
        header must carry it instead. This is the primary defense from
        issue #4184 — reverting to the old ``?key=`` form would make this
        test fail before any exception-handling code even runs.
        """
        import local_deep_research.security as sec_pkg

        captured = {}

        class _Resp:
            status_code = 200

            def json(self):
                return {"models": []}

        def _capture(url, *args, **kwargs):
            captured["url"] = url
            captured["headers"] = kwargs.get("headers") or {}
            return _Resp()

        with patch.object(sec_pkg, "safe_get", side_effect=_capture):
            google_provider_module.GoogleProvider.list_models_for_api(
                api_key=_LEAKED_KEY
            )

        assert "url" in captured, "safe_get was not called"
        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in captured["url"], (
                "API key found in the URL handed to safe_get (as encoding "
                f"{encoding!r}) - The Google provider must pass the key via the"
                " x-goog-api-key header (see issue #4184) so HTTP exception"
                " messages — which embed the URL but not headers — cannot "
                "carry the secret."
            )
        assert captured["headers"].get("x-goog-api-key") == _LEAKED_KEY, (
            "API key must be passed via the x-goog-api-key header. "
            f"Got headers: {captured['headers']!r}"
        )

    def test_no_leak_when_safe_get_raises_with_url_in_message(
        self, loguru_caplog, google_provider_module
    ):
        """Defense-in-depth: even if some upstream exception still embeds
        the key (e.g., a future code path reintroduces it, or an
        intermediate proxy echoes it back), the except handler's
        ``redact_secrets`` wrap must scrub it from the logged message.
        """
        import local_deep_research.security as sec_pkg

        exc = requests.exceptions.ConnectionError(
            "HTTPSConnectionPool(host='generativelanguage.googleapis.com', "
            "port=443): Max retries exceeded with url: "
            f"/v1beta/models?key={_LEAKED_KEY}"
        )

        with loguru_caplog.at_level("DEBUG"):
            with patch.object(sec_pkg, "safe_get", side_effect=exc):
                result = (
                    google_provider_module.GoogleProvider.list_models_for_api(
                        api_key=_LEAKED_KEY
                    )
                )

        assert result == []
        assert _LEAKED_KEY not in loguru_caplog.text, (
            "API key value leaked into logs via the upstream exception "
            "message. The except handler must redact the key before "
            "logging."
        )
        # Sanity: we did log something — proving the test exercised the
        # except branch rather than passing trivially.
        assert "Error fetching Google Gemini models" in loguru_caplog.text

    def test_no_leak_when_safe_get_raises_generic_runtime_error(
        self, loguru_caplog, google_provider_module
    ):
        """Some upstream failures raise a generic exception whose ``str()``
        contains the URL. Redaction must handle that path too.
        """
        import local_deep_research.security as sec_pkg

        exc = RuntimeError(
            f"upstream failure calling /v1beta/models?key={_LEAKED_KEY}"
        )

        with loguru_caplog.at_level("DEBUG"):
            with patch.object(sec_pkg, "safe_get", side_effect=exc):
                google_provider_module.GoogleProvider.list_models_for_api(
                    api_key=_LEAKED_KEY
                )

        assert _LEAKED_KEY not in loguru_caplog.text

    def test_non_200_response_does_not_leak_key(
        self, loguru_caplog, google_provider_module
    ):
        """The status-code branch must also not surface the URL. The
        existing warning at line 88-90 only includes ``response.status_code``
        — verify that contract holds.
        """
        import local_deep_research.security as sec_pkg

        class _Resp:
            status_code = 503
            text = "upstream busy"

            def json(self):
                return {}

        with loguru_caplog.at_level("DEBUG"):
            with patch.object(sec_pkg, "safe_get", return_value=_Resp()):
                result = (
                    google_provider_module.GoogleProvider.list_models_for_api(
                        api_key=_LEAKED_KEY
                    )
                )

        assert result == []
        assert _LEAKED_KEY not in loguru_caplog.text


class TestCredentialStoreKeyLeakage:
    """Pin no-leak behavior in the credential store base class. The class
    is a small wrapper around a dict; verify ``__repr__``,
    ``__str__``, and any exception paths do not expose stored secrets.
    """

    def test_repr_does_not_expose_stored_passwords(self):
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        store = SchedulerCredentialStore(ttl_hours=1)
        store.store("alice", _LEAKED_KEY)

        # repr / str must not expose the password
        assert _LEAKED_KEY not in repr(store)
        assert _LEAKED_KEY not in str(store)

    def test_clear_entry_does_not_log_store_state(self, loguru_caplog):
        """``clear_entry`` must be completely silent. The implementation
        in ``credential_store_base.py`` does not call ``logger`` at all
        — this test pins that contract so a future
        ``logger.debug(f"store contents: {self._store}")`` (which would
        expose every stored credential) is caught immediately. Exercises
        both the present-key and missing-key paths and asserts not just
        the leaked sentinel but that *no records were emitted at all*.
        """
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        store = SchedulerCredentialStore(ttl_hours=1)
        store.store("alice", _LEAKED_KEY)
        store.store("bob", "another-stored-secret-87654321")

        with loguru_caplog.at_level("DEBUG"):
            store.clear_entry("never-stored-user")
            store.clear_entry("alice")

        assert not loguru_caplog.records, (
            "clear_entry must be silent. Got log records: "
            f"{[r.getMessage() for r in loguru_caplog.records]}"
        )
        assert _LEAKED_KEY not in loguru_caplog.text


class TestOpenAICompatErrorRedaction:
    """The OpenAI-compat error helper at
    ``src/local_deep_research/error_handling/openai_compat_errors.py``
    runs ``_strip_credentials`` on ``base_url`` and appends ``{exc!s}`` to
    the returned friendly message. Verify that an embedded-credential
    base URL is stripped from the final string.
    """

    def test_friendly_error_strips_credentials_from_base_url(self):
        from local_deep_research.error_handling.openai_compat_errors import (
            friendly_openai_compatible_error,
        )

        # Some users embed API keys in the base URL itself
        embedded_url = f"https://user:{_LEAKED_KEY}@host.example.com/v1"
        exc = RuntimeError("upstream failed")  # exc!s does NOT contain the key

        result = friendly_openai_compatible_error(
            exc,
            provider="lmstudio",
            base_url=embedded_url,
            model="some-model",
        )

        assert _LEAKED_KEY not in result, (
            "_strip_credentials must remove userinfo from base_url before "
            "the URL is embedded in the friendly message"
        )


class TestOpenAIBaseProviderKeyLeakage:
    """``OpenAICompatibleProvider.list_models`` wraps the inner
    ``list_models_for_api`` call. If a subclass override raises (or the
    settings-fetch path raises while the api_key is in scope), the
    upstream exception's ``str()`` may embed the key. The except block at
    ``openai_base.py`` redacts the key from the exception string and uses
    ``logger.warning`` (no traceback) to keep the cause chain off the log.

    These tests use ``loguru_caplog_full`` — the stricter fixture that
    captures the rendered exception block — so a leak that lives only in
    the traceback would be caught. The vanilla ``loguru_caplog`` fixture
    uses ``format='{message}'`` and would false-pass on a traceback leak.
    """

    def test_no_leak_when_inner_call_raises_with_url_in_message(
        self, loguru_caplog_full
    ):
        """A subclass override of ``list_models_for_api`` that constructs
        the auth URL with the key in a query parameter (the Google
        pattern) and then raises a ``requests`` exception embedding that
        URL must not leak the key.
        """
        from local_deep_research.llm.providers.openai_base import (
            OpenAICompatibleProvider,
        )

        exc = requests.exceptions.ConnectionError(
            "HTTPSConnectionPool: Max retries exceeded with url: "
            f"/v1/models?key={_LEAKED_KEY}"
        )

        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(
                OpenAICompatibleProvider,
                "list_models_for_api",
                side_effect=exc,
            ):
                with patch.object(
                    OpenAICompatibleProvider,
                    "requires_auth_for_models",
                    return_value=False,
                ):
                    with patch(
                        "local_deep_research.config.thread_settings."
                        "get_setting_from_snapshot",
                        return_value=_LEAKED_KEY,
                    ):
                        # Subclass override drives auth-required to True so
                        # api_key flows through the settings path.
                        OpenAICompatibleProvider.requires_auth_for_models = (
                            classmethod(lambda cls: True)
                        )
                        try:
                            result = OpenAICompatibleProvider.list_models()
                        finally:
                            del OpenAICompatibleProvider.requires_auth_for_models

        assert result == []
        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"API key leaked into logs as encoding {encoding!r}. "
                f"The except handler must pass the key to redact_secrets "
                f"and use logger.warning (not logger.exception)."
            )
        # Sanity: we did exercise the except branch.
        assert "Error listing models" in loguru_caplog_full.text

    def test_no_leak_when_inner_call_raises_generic_runtime_error(
        self, loguru_caplog_full
    ):
        """Some upstream failures raise generic exceptions whose ``str()``
        embeds the URL. Redaction must handle that path too — not just
        ``requests`` exception subclasses.
        """
        from local_deep_research.llm.providers.openai_base import (
            OpenAICompatibleProvider,
        )

        exc = RuntimeError(
            f"call to /v1/models?key={_LEAKED_KEY} failed: connection reset"
        )

        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(
                OpenAICompatibleProvider,
                "list_models_for_api",
                side_effect=exc,
            ):
                with patch(
                    "local_deep_research.config.thread_settings."
                    "get_setting_from_snapshot",
                    return_value=_LEAKED_KEY,
                ):
                    OpenAICompatibleProvider.requires_auth_for_models = (
                        classmethod(lambda cls: True)
                    )
                    try:
                        OpenAICompatibleProvider.list_models()
                    finally:
                        del OpenAICompatibleProvider.requires_auth_for_models

        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"API key leaked as encoding {encoding!r}"
            )


class TestOpenAIEmbeddingProviderKeyLeakage:
    """``OpenAIEmbeddingsProvider.get_available_models`` calls the OpenAI
    SDK's ``client.models.list()`` with the api_key in scope. If the SDK
    raises (e.g., the base_url points at a misconfigured proxy that
    echoes the bearer token in its error body), the except handler at
    ``embeddings/providers/implementations/openai.py`` must redact the
    key before logging.
    """

    def test_no_leak_when_models_list_raises_with_key_in_message(
        self, loguru_caplog_full
    ):
        from local_deep_research.embeddings.providers.implementations.openai import (
            OpenAIEmbeddingsProvider,
        )

        def _settings_lookup(key, default=None, settings_snapshot=None):
            if key == "embeddings.openai.api_key":
                return _LEAKED_KEY
            if key == "embeddings.openai.base_url":
                return None
            return default

        exc = RuntimeError(
            f"upstream proxy echoed Authorization: Bearer {_LEAKED_KEY}"
        )

        class _Models:
            def list(self_inner):
                raise exc

        class _FakeOpenAI:
            def __init__(self, **kwargs):
                self.models = _Models()

        with loguru_caplog_full.at_level("DEBUG"):
            with patch(
                "local_deep_research.embeddings.providers.implementations."
                "openai.get_setting_from_snapshot",
                side_effect=_settings_lookup,
            ):
                with patch("openai.OpenAI", _FakeOpenAI):
                    result = OpenAIEmbeddingsProvider.get_available_models()

        assert result == []
        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"API key leaked as encoding {encoding!r}. The except "
                f"handler at openai.py:218 must redact via "
                f"redact_secrets(str(e), api_key) and use logger.warning."
            )
        # Sanity: the except branch ran.
        assert (
            "Error fetching OpenAI embedding models" in loguru_caplog_full.text
        )


class TestSearchEngineKeyLeakage:
    """Pin no-leak behavior across representative search engines whose
    HTTP-call error paths were wrapped in #4131. The base-class fix in
    ``search_engine_base.py`` covers the catch-all path, but engines
    that catch+swallow inside ``_get_previews`` need their own wrap —
    these tests cover those direct call sites.
    """

    def test_tavily_get_previews_does_not_leak_on_request_exception(
        self, loguru_caplog_full
    ):
        """Tavily's ``_get_previews`` catches
        ``requests.exceptions.RequestException`` and logs it. The except
        block must redact ``self.api_key`` from the rendered exception
        text. We drive this by patching ``safe_post`` to raise an
        exception whose ``str()`` embeds the sentinel in the URL.

        Tavily is the representative engine for this contract; the
        base-class fix in ``search_engine_base.py`` covers the
        catch-all path for all subclasses (see ``run()``). Other
        engines have engine-specific attribute requirements that make
        a parametrized test brittle without a fuller fixture suite —
        tracked as follow-up to #4131.
        """
        from local_deep_research.web_search_engines.engines import (
            search_engine_tavily as mod,
        )

        EngineCls = mod.TavilySearchEngine
        engine_class = "TavilySearchEngine"
        api_attr = "api_key"

        # Build an instance without going through normal __init__ — the
        # engines have settings-dependent __init__ paths that complicate
        # test setup. Provide every attribute referenced before safe_post
        # so the test actually reaches the leak-vector code path.
        engine = EngineCls.__new__(EngineCls)
        setattr(engine, api_attr, _LEAKED_KEY)
        engine.max_results = 10
        engine.search_depth = "basic"
        engine.include_full_content = False
        engine.include_domains = []
        engine.exclude_domains = []
        engine.base_url = "https://api.example.com"
        engine.engine_type = "test_engine"
        engine._search_results = None
        # Bypass the rate-limit-tracker dependency — see _stub_rate_tracker.
        # Tavily/Exa each call ``self.rate_tracker.apply_rate_limit(...)``
        # before the HTTP request.
        engine.rate_tracker = _stub_rate_tracker()
        # ``_raise_if_rate_limit`` is a base-class method that inspects
        # the response status code; with a non-HTTP exception passed in,
        # it must not itself raise. Stub it to a no-op for safety.
        engine._raise_if_rate_limit = lambda *a, **k: None

        exc = requests.exceptions.ConnectionError(
            f"HTTPSConnectionPool: Max retries exceeded with url: "
            f"/v1/search?api_key={_LEAKED_KEY}"
        )

        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(mod, "safe_post", side_effect=exc, create=True):
                try:
                    engine._get_previews("test query")
                except Exception:
                    pass

        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"{engine_class} leaked api_key as encoding {encoding!r}. "
                f"The except block must call "
                f"redact_secrets(str(e), self.{api_attr}) before logging."
            )
        # Sanity: the except branch must have actually run — otherwise the
        # leak assertion passes trivially.
        assert "Error getting Tavily" in loguru_caplog_full.text, (
            f"{engine_class} test did not exercise the except branch. "
            f"Captured logs: {loguru_caplog_full.text!r}"
        )

    def test_nasa_ads_get_previews_does_not_leak_on_request_exception(
        self, loguru_caplog_full
    ):
        """NASA ADS sends its key as an ``Authorization: Bearer`` header
        (``self.headers``) and its ``_get_previews`` catch-all logged the
        exception unredacted. This engine was *not* in the original #4131
        engine list — it is covered by the #4131 follow-up. The except
        block must redact ``self.api_key`` from the rendered exception
        text. We drive this by patching ``safe_get`` to raise an exception
        whose ``str()`` embeds the sentinel in the URL.
        """
        from local_deep_research.web_search_engines.engines import (
            search_engine_nasa_ads as mod,
        )

        EngineCls = mod.NasaAdsSearchEngine

        engine = EngineCls.__new__(EngineCls)
        engine.api_key = _LEAKED_KEY
        engine.headers = {"Authorization": f"Bearer {_LEAKED_KEY}"}
        engine.api_base = "https://api.adsabs.harvard.edu/v1"
        engine.max_results = 10
        engine.sort_by = "relevance"
        engine.from_publication_date = None
        engine.min_citations = 0
        engine.include_arxiv = True
        engine.engine_type = "test_engine"
        engine.rate_tracker = _stub_rate_tracker()

        exc = requests.exceptions.ConnectionError(
            f"HTTPSConnectionPool: Max retries exceeded with url: "
            f"/v1/search/query?token={_LEAKED_KEY}"
        )

        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(mod, "safe_get", side_effect=exc, create=True):
                try:
                    engine._get_previews("test query")
                except Exception:
                    pass

        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in loguru_caplog_full.text, (
                "NASAADSSearchEngine leaked api_key as encoding "
                f"{encoding!r}. The except block must call "
                "redact_secrets(str(e), self.api_key) before logging."
            )
        # Sanity: the except branch must have actually run.
        assert "Error searching NASA ADS" in loguru_caplog_full.text, (
            "NASA ADS test did not exercise the except branch. "
            f"Captured logs: {loguru_caplog_full.text!r}"
        )

    def test_exa_get_previews_does_not_leak_header_key(
        self, loguru_caplog_full
    ):
        """Exa sends its key in the ``x-api-key`` header. After the
        _scrub_error centralization, the catch block relies on the literal
        pass (self.api_key via _secret_attrs) — the regex cannot scrub a raw
        header value. Use an OPAQUE key embedded header-style so only the
        literal pass can redact it; this pins that exa keeps its net.
        """
        from local_deep_research.web_search_engines.engines import (
            search_engine_exa as mod,
        )

        engine = mod.ExaSearchEngine.__new__(mod.ExaSearchEngine)
        engine.api_key = _LEAKED_OPAQUE_KEY
        engine.max_results = 10
        engine.search_type = "auto"
        engine.include_domains = []
        engine.exclude_domains = []
        engine.start_published_date = None
        engine.end_published_date = None
        engine.category = None
        engine.include_full_content = False
        engine.base_url = "https://api.exa.ai"
        engine.engine_type = "test_engine"
        engine.rate_tracker = _stub_rate_tracker()
        engine._raise_if_rate_limit = lambda *a, **k: None

        exc = requests.exceptions.ConnectionError(
            f"request failed; sent x-api-key: {_LEAKED_OPAQUE_KEY}"
        )
        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(mod, "safe_post", side_effect=exc, create=True):
                try:
                    engine._get_previews("test query")
                except Exception:
                    pass

        for encoding in _all_encodings_of(_LEAKED_OPAQUE_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"ExaSearchEngine leaked header api_key as {encoding!r}."
            )
        assert "Error getting Exa results" in loguru_caplog_full.text

    def test_serper_get_previews_does_not_leak_header_key(
        self, loguru_caplog_full
    ):
        """Serper sends its key in the ``X-API-KEY`` header — same literal-pass
        contract as exa. Opaque key embedded header-style."""
        from local_deep_research.web_search_engines.engines import (
            search_engine_serper as mod,
        )

        engine = mod.SerperSearchEngine.__new__(mod.SerperSearchEngine)
        engine.api_key = _LEAKED_OPAQUE_KEY
        engine.max_results = 10
        engine.region = "us"
        engine.search_language = "en"
        engine.time_period = None
        engine.base_url = "https://google.serper.dev/search"
        engine.engine_type = "test_engine"
        engine.rate_tracker = _stub_rate_tracker()
        engine._raise_if_rate_limit = lambda *a, **k: None

        exc = requests.exceptions.ConnectionError(
            f"request failed; sent X-API-KEY: {_LEAKED_OPAQUE_KEY}"
        )
        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(mod, "safe_post", side_effect=exc, create=True):
                try:
                    engine._get_previews("test query")
                except Exception:
                    pass

        for encoding in _all_encodings_of(_LEAKED_OPAQUE_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"SerperSearchEngine leaked header api_key as {encoding!r}."
            )
        assert "Error getting Serper API results" in loguru_caplog_full.text


class TestSearchEngineParamsKeyLeakage:
    """Pin no-leak behavior for search engines that pass the API key via
    the ``params=`` dict to ``safe_get``. The underlying ``requests``
    library assembles ``params`` into the request URL before the network
    call; when ``requests`` raises (``ConnectionError``, ``Timeout``,
    etc.), the exception's ``__str__()`` includes that assembled URL —
    and therefore the key.

    Each engine catches the exception in its own try/except and is
    protected today by either ``_sanitize_error_message()``
    (regex-based, defined on the base class) and/or
    ``redact_secrets()`` (literal-value substitution). This contract is
    "safe by convention" — every catch site must remember the wrap. A
    future refactor that consolidates error handling into a base method
    without one of those calls would silently leak. The tests below
    pin the no-leak property so such a regression flips the test.

    Note on Guardian specifically: its key parameter is ``api-key`` (with
    a dash), which the base-class regex at ``search_engine_base.py:984``
    does NOT match (the regex covers ``api_key|apikey|key|token|secret``).
    Guardian therefore relies entirely on ``redact_secrets()`` for the
    URL-leak path — making it the most fragile of the four and the most
    important to pin.
    """

    @staticmethod
    def _mojeek_engine():
        from local_deep_research.web_search_engines.engines import (
            search_engine_mojeek as mod,
        )

        engine = mod.MojeekSearchEngine.__new__(mod.MojeekSearchEngine)
        engine.api_key = _LEAKED_KEY
        engine.search_url = "https://api.mojeek.com/search"
        engine.max_results = 10
        engine.safe_search = True
        engine.language = None
        engine.region = None
        return (
            mod,
            engine,
            "_get_search_results",
            "/search?api_key=",
            "Error when searching using Mojeek",
        )

    @staticmethod
    def _scaleserp_engine():
        from local_deep_research.web_search_engines.engines import (
            search_engine_scaleserp as mod,
        )

        engine = mod.ScaleSerpSearchEngine.__new__(mod.ScaleSerpSearchEngine)
        engine.api_key = _LEAKED_KEY
        engine.base_url = "https://api.scaleserp.com/search"
        engine.max_results = 10
        engine.location = "United States"
        engine.language = "en"
        engine.device = "desktop"
        engine.safe_search = False
        engine.enable_cache = False
        engine.engine_type = "test_engine"
        engine._knowledge_graph = None
        engine._search_results = None
        engine.rate_tracker = _stub_rate_tracker()
        engine._raise_if_rate_limit = lambda *a, **k: None
        return (
            mod,
            engine,
            "_get_previews",
            "/search?api_key=",
            "Error getting ScaleSerp API results",
        )

    @staticmethod
    def _google_pse_engine():
        from local_deep_research.web_search_engines.engines import (
            search_engine_google_pse as mod,
        )

        engine = mod.GooglePSESearchEngine.__new__(mod.GooglePSESearchEngine)
        engine.api_key = _LEAKED_KEY
        engine.search_engine_id = "test-cx"
        engine.max_results = 10
        engine.safe = "off"
        engine.language = "en"
        engine.region = "us"
        engine.max_retries = 1  # single attempt — no real sleep, no real retry
        engine.retry_delay = 0
        engine.engine_type = "test_engine"
        engine.rate_tracker = _stub_rate_tracker()
        # _make_request will re-raise as RequestException after max_retries
        # — callers must catch that. Return the method name to invoke.
        # The retry loop logs each attempt before re-raising, so the log
        # marker is the per-attempt warning from line ~266.
        return (
            mod,
            engine,
            "_make_request",
            "/customsearch/v1?key=",
            "Request error on attempt",
        )

    @staticmethod
    def _guardian_engine():
        from local_deep_research.web_search_engines.engines import (
            search_engine_guardian as mod,
        )

        engine = mod.GuardianSearchEngine.__new__(mod.GuardianSearchEngine)
        engine.api_key = _LEAKED_KEY
        engine.api_url = "https://content.guardianapis.com/search"
        engine.max_results = 10
        engine.from_date = "2024-01-01"
        engine.to_date = "2024-12-31"
        engine.order_by = "relevance"
        engine.section = None
        engine.engine_type = "test_engine"
        engine.rate_tracker = _stub_rate_tracker()
        engine._raise_if_rate_limit = lambda *a, **k: None
        return (
            mod,
            engine,
            "_get_all_data",
            "/search?api-key=",
            "Error getting data from The Guardian API",
        )

    @staticmethod
    def _pubmed_engine():
        from local_deep_research.web_search_engines.engines import (
            search_engine_pubmed as mod,
        )

        engine = mod.PubMedSearchEngine.__new__(mod.PubMedSearchEngine)
        engine.api_key = _LEAKED_KEY
        engine.search_url = (
            "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
        )
        engine.engine_type = "test_engine"
        engine.rate_tracker = _stub_rate_tracker()
        return (
            mod,
            engine,
            "_get_result_count",
            "?api_key=",
            "Error getting result count",
        )

    @pytest.mark.parametrize(
        "engine_factory_name",
        [
            "_mojeek_engine",
            "_scaleserp_engine",
            "_google_pse_engine",
            "_guardian_engine",
            "_pubmed_engine",
        ],
    )
    def test_no_leak_when_safe_get_raises_with_assembled_url(
        self, loguru_caplog_full, engine_factory_name
    ):
        """When ``safe_get`` raises a ``ConnectionError`` whose message
        embeds the assembled URL (base + query string with the key from
        ``params=``), the engine's catch block must scrub the key before
        logging.

        Mutation check: temporarily remove the per-engine
        ``redact_secrets()`` / ``_sanitize_error_message()`` call from
        the catch block and the relevant parametrized case will fail.
        """
        factory = getattr(self, engine_factory_name)
        mod, engine, method_name, url_path_with_key, marker = factory()

        # Simulate the exception that requests raises when the network
        # call fails — its message embeds the *assembled* URL with the
        # query string built from `params=`.
        exc = requests.exceptions.ConnectionError(
            f"HTTPSConnectionPool: Max retries exceeded with url: "
            f"{url_path_with_key}{_LEAKED_KEY}&q=test"
        )

        # No create=True: every engine under test imports safe_get at
        # module level, so a typo'd patch target (or a future refactor
        # that drops the import) must fail loudly rather than silently
        # no-op into a passing test.
        with loguru_caplog_full.at_level("DEBUG"):
            with patch.object(
                mod, "safe_get", side_effect=exc
            ) as mock_safe_get:
                # Only swallow the request-layer exception we injected.
                # Google PSE's _make_request re-raises as
                # RequestException after retries; the other three engines
                # catch internally and return []. AttributeError /
                # TypeError from a bad test stub must propagate so the
                # test fails loudly instead of silently skipping the
                # logging/redaction path.
                try:
                    getattr(engine, method_name)("test query")
                except requests.exceptions.RequestException:
                    pass

        # Sanity: the SUT must have actually called safe_get.
        assert mock_safe_get.called, (
            f"{engine_factory_name}: safe_get was not invoked — the "
            f"redaction path under test never ran. Check that "
            f"{mod.__name__} still imports safe_get at module level."
        )
        # Sanity: the SUT must have actually logged the catch-block warning.
        # Without this, the leak assertion above passes vacuously if the
        # catch block is removed or refactored to silently re-raise.
        assert marker in loguru_caplog_full.text, (
            f"{engine_factory_name}: catch-block log marker {marker!r} not "
            f"emitted — the redaction path under test never ran. Check that "
            f"{mod.__name__} still wraps the safe_get call with a "
            f"logger.warning(...) that includes the redacted message. "
            f"Captured logs: {loguru_caplog_full.text!r}"
        )

        for encoding in _all_encodings_of(_LEAKED_KEY):
            assert encoding not in loguru_caplog_full.text, (
                f"{engine_factory_name}: api_key leaked as encoding "
                f"{encoding!r}. The catch block must scrub the key via "
                f"redact_secrets(str(e), self.api_key) and/or "
                f"self._sanitize_error_message(str(e)) before logging."
            )
