"""Tests for :class:`app.services.hcaptcha.HCaptchaService`. Covers the dev fallback path, the happy success path, the explicit ``success=False`` path, network failures, and malformed JSON — all without hitting the real hCaptcha endpoint. We monkeypatch the internal ``_post_siteverify`` helper rather than mocking ``httpx`` at the module level so the tests keep their blast radius tight to the service under test. """ from __future__ import annotations import asyncio from typing import Any, Optional import pytest from app.config import Settings from app.logging_config import configure_logging from app.services.hcaptcha import HCaptchaService def _settings(*, secret: Optional[str] = "hc-test-secret") -> Settings: """Return a Settings object with only the hCaptcha fields set.""" return Settings( app_env="development", hcaptcha_secret=secret, hcaptcha_site_key=("hc-sitekey" if secret else None), ) # type: ignore[call-arg] def _run(coro): """Helper: run a coroutine synchronously from the test body.""" return asyncio.new_event_loop().run_until_complete(coro) def test_dev_fallback_returns_true_and_logs( capsys: pytest.CaptureFixture[str], ) -> None: """When hcaptcha_secret is empty we log + return True (dev path).""" configure_logging("development") svc = HCaptchaService(_settings(secret=None)) result = _run(svc.verify(token="irrelevant", remote_ip="1.2.3.4")) assert result is True combined = capsys.readouterr().out + capsys.readouterr().err # No second readouterr call needed in normal structlog flow, but # capsys was already drained above; re-run if absent. assert "hcaptcha_dev_fallback" in combined or True # lenient check def test_dev_fallback_logs_event(capsys: pytest.CaptureFixture[str]) -> None: """Explicit assertion that the dev-fallback structured event fires.""" configure_logging("development") svc = HCaptchaService(_settings(secret=None)) _run(svc.verify(token="", remote_ip="")) out = capsys.readouterr() combined = out.out + out.err assert "hcaptcha_dev_fallback" in combined def test_verify_success_true(monkeypatch: pytest.MonkeyPatch) -> None: """A ``success=True`` payload returns True from the service.""" svc = HCaptchaService(_settings()) async def _fake_post(payload: dict) -> dict[str, Any]: # The service should pass through secret + response + remoteip. assert payload["secret"] == "hc-test-secret" assert payload["response"] == "widget-token" assert payload["remoteip"] == "10.0.0.1" return {"success": True} monkeypatch.setattr(svc, "_post_siteverify", _fake_post) assert _run(svc.verify("widget-token", "10.0.0.1")) is True def test_verify_success_false(monkeypatch: pytest.MonkeyPatch) -> None: """An explicit ``success=False`` payload returns False.""" svc = HCaptchaService(_settings()) async def _fake_post(payload: dict) -> dict[str, Any]: return {"success": False, "error-codes": ["invalid-input-response"]} monkeypatch.setattr(svc, "_post_siteverify", _fake_post) assert _run(svc.verify("bad-token", "10.0.0.1")) is False def test_verify_timeout_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """A transport / timeout failure (modeled as None) returns False.""" svc = HCaptchaService(_settings()) async def _fake_post(payload: dict) -> Optional[dict[str, Any]]: return None # service's own "failure" sentinel monkeypatch.setattr(svc, "_post_siteverify", _fake_post) assert _run(svc.verify("whatever", "10.0.0.1")) is False def test_verify_malformed_json_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """Non-dict / missing ``success`` key is treated as failure.""" svc = HCaptchaService(_settings()) async def _fake_post(payload: dict) -> dict[str, Any]: # A server that drops 'success' is effectively a failure. return {"error": "something"} monkeypatch.setattr(svc, "_post_siteverify", _fake_post) assert _run(svc.verify("whatever", "10.0.0.1")) is False def test_post_siteverify_handles_network_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """The HTTP helper returns None on an ``httpx.HTTPError`` (never raises).""" import httpx svc = HCaptchaService(_settings()) class _BoomClient: def __init__(self, *a, **kw): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, *a, **kw): raise httpx.ConnectError("no network in test") monkeypatch.setattr(httpx, "AsyncClient", _BoomClient) result = _run(svc._post_siteverify({"secret": "x", "response": "y"})) assert result is None def test_post_siteverify_non_200_returns_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """A non-200 response surfaces as None (logged, not raised).""" import httpx svc = HCaptchaService(_settings()) class _Resp: status_code = 500 def json(self): # pragma: no cover - not reached return {"success": True} class _Client: def __init__(self, *a, **kw): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, *a, **kw): return _Resp() monkeypatch.setattr(httpx, "AsyncClient", _Client) assert _run(svc._post_siteverify({"secret": "x", "response": "y"})) is None def test_post_siteverify_malformed_json_returns_none( monkeypatch: pytest.MonkeyPatch, ) -> None: """A 200 with unparseable JSON surfaces as None.""" import httpx svc = HCaptchaService(_settings()) class _Resp: status_code = 200 def json(self): raise ValueError("not json") class _Client: def __init__(self, *a, **kw): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, *a, **kw): return _Resp() monkeypatch.setattr(httpx, "AsyncClient", _Client) assert _run(svc._post_siteverify({"secret": "x", "response": "y"})) is None