From 8bb58b44b3c57b74ffe681db7b46ce33da88f3bb Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 12:05:12 +0300 Subject: [PATCH] Revert "Dev4" --- app/external/yookassa_webhook.py | 181 +++++++++++++++++- app/webserver/payments.py | 30 +++ tests/external/test_yookassa_webhook.py | 159 ++++++++-------- tests/webserver/test_payments.py | 234 ++++++++++++++++++++++++ 4 files changed, 515 insertions(+), 89 deletions(-) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 237f6ee6..8ddd1d53 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -1,17 +1,38 @@ import asyncio -import json import logging -from typing import Any, Dict - +import json +from ipaddress import ( + IPv4Address, + IPv4Network, + IPv6Address, + IPv6Network, + ip_address, + ip_network, +) +from typing import Iterable, Optional, Dict, Any, List, Union, Tuple from aiohttp import web from app.config import settings -from app.database.database import get_db from app.services.payment_service import PaymentService +from app.database.database import get_db logger = logging.getLogger(__name__) +IPAddress = Union[IPv4Address, IPv6Address] +IPNetwork = Union[IPv4Network, IPv6Network] + +YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = ( + ip_network("185.71.76.0/27"), + ip_network("185.71.77.0/27"), + ip_network("77.75.153.0/25"), + ip_network("77.75.154.128/25"), + ip_network("77.75.156.11/32"), + ip_network("77.75.156.35/32"), + ip_network("2a02:5180::/32"), +) + + YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = ( "payment.succeeded", "payment.waiting_for_capture", @@ -19,6 +40,132 @@ YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = ( ) +def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]: + candidates: List[str] = [] + for value in values: + if not value: + continue + for part in value.split(","): + normalized = part.strip() + if normalized: + candidates.append(normalized) + return candidates + + +def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]: + value = candidate.strip() + if not value: + return None + + if value.startswith("[") and "]" in value: + value = value[1:value.index("]")] + + if "%" in value: + value = value.split("%", 1)[0] + + if value.count(":") == 1 and "." in value: + host, _, port = value.rpartition(":") + if port.isdigit(): + value = host + + try: + return ip_address(value) + except ValueError: + return None + + +def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool: + if remote_ip is None: + return True + + if _is_trusted_proxy_ip(remote_ip): + return True + + return any( + getattr(remote_ip, attribute) + for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved") + ) + + +_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ()) + + +def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]: + global _TRUSTED_PROXY_NETWORKS_CACHE + + raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or "" + cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE + + if raw_value == cached_raw: + return cached_networks + + networks: List[IPNetwork] = [] + for part in raw_value.split(","): + candidate = part.strip() + if not candidate: + continue + + try: + networks.append(ip_network(candidate, strict=False)) + except ValueError: + logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate) + + cached_networks = tuple(networks) + _TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks) + return cached_networks + + +def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool: + if any( + getattr(ip_object, attribute) + for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved") + ): + return True + + return any(ip_object in network for network in _get_trusted_proxy_networks()) + + +def resolve_yookassa_ip( + candidates: Iterable[str], + *, + remote: Optional[str] = None, +) -> Optional[IPAddress]: + remote_ip = _parse_candidate_ip(remote) if remote else None + + if ( + remote_ip is not None + and remote_ip.is_global + and not _is_trusted_proxy_ip(remote_ip) + ): + return remote_ip + + candidate_list = list(candidates) + + if _should_trust_forwarded_headers(remote_ip): + last_hop = remote_ip + for candidate in reversed(candidate_list): + ip_object = _parse_candidate_ip(candidate) + if ip_object is not None: + if last_hop is None or _is_trusted_proxy_ip(last_hop): + if _is_trusted_proxy_ip(ip_object): + last_hop = ip_object + continue + return ip_object + break + + if last_hop is not None and not _is_trusted_proxy_ip(last_hop): + return last_hop + + return remote_ip if remote_ip is not None else next( + (ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None), + None, + ) + + +def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool: + return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS) + + class YooKassaWebhookHandler: def __init__(self, payment_service: PaymentService): @@ -30,6 +177,32 @@ class YooKassaWebhookHandler: logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}") logger.info(f"📋 Headers: {dict(request.headers)}") + header_ip_candidates = collect_yookassa_ip_candidates( + request.headers.get("X-Forwarded-For"), + request.headers.get("X-Real-IP"), + ) + client_ip = resolve_yookassa_ip( + header_ip_candidates, + remote=request.remote, + ) + + if client_ip is None: + logger.warning( + "🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s", + header_ip_candidates + ([request.remote] if request.remote else []), + ) + return web.Response(status=403, text="Forbidden") + + if not is_yookassa_ip_allowed(client_ip): + logger.warning( + "🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)", + client_ip, + ", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS), + ) + return web.Response(status=403, text="Forbidden") + + logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip) + body = await request.text() if not body: diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 5b352c9d..e78a5514 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -346,6 +346,36 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute @router.post(settings.YOOKASSA_WEBHOOK_PATH) async def yookassa_webhook(request: Request) -> JSONResponse: + header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates( + request.headers.get("X-Forwarded-For"), + request.headers.get("X-Real-IP"), + ) + remote_ip = request.client.host if request.client else None + client_ip = yookassa_webhook_module.resolve_yookassa_ip( + header_ip_candidates, + remote=remote_ip, + ) + + if client_ip is None: + return JSONResponse( + { + "status": "error", + "reason": "unknown_ip", + "candidates": header_ip_candidates + ([remote_ip] if remote_ip else []), + }, + status_code=status.HTTP_403_FORBIDDEN, + ) + + if not yookassa_webhook_module.is_yookassa_ip_allowed(client_ip): + return JSONResponse( + { + "status": "error", + "reason": "forbidden_ip", + "ip": str(client_ip), + }, + status_code=status.HTTP_403_FORBIDDEN, + ) + body_bytes = await request.body() if not body_bytes: return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST) diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index 35af5f57..6a210968 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -7,7 +7,13 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer from app.config import settings -from app.external.yookassa_webhook import create_yookassa_webhook_app +from app.external.yookassa_webhook import ( + create_yookassa_webhook_app, + resolve_yookassa_ip, +) + + +ALLOWED_IP = "185.71.76.10" class DummyDB: @@ -19,22 +25,84 @@ def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False) monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False) monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False) def _build_headers(**overrides: str) -> dict[str, str]: - headers = {"Content-Type": "application/json"} + headers = { + "Content-Type": "application/json", + "X-Forwarded-For": ALLOWED_IP, + } headers.update(overrides) return headers -def _json_bytes(payload: dict) -> bytes: - return json.dumps(payload, ensure_ascii=False).encode("utf-8") +@pytest.mark.parametrize( + ("remote", "expected"), + ( + ("185.71.76.10", "185.71.76.10"), + ("8.8.8.8", "8.8.8.8"), + ("10.0.0.5", "185.71.76.10"), + (None, "185.71.76.10"), + ), +) +def test_resolve_yookassa_ip_trust_rules(remote: str | None, expected: str) -> None: + candidates = [ALLOWED_IP] + ip_object = resolve_yookassa_ip(candidates, remote=remote) + + assert ip_object is not None + assert str(ip_object) == expected + + +def test_resolve_yookassa_ip_prefers_last_forwarded_candidate() -> None: + candidates = ["185.71.76.10", "8.8.8.8"] + + ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5") + + assert ip_object is not None + assert str(ip_object) == "8.8.8.8" + + +def test_resolve_yookassa_ip_accepts_allowed_last_forwarded_candidate() -> None: + candidates = ["8.8.8.8", ALLOWED_IP] + + ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5") + + assert ip_object is not None + assert str(ip_object) == ALLOWED_IP + + +def test_resolve_yookassa_ip_skips_trusted_proxy_hops(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False) + + candidates = [ALLOWED_IP, "203.0.113.10"] + + ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5") + + assert ip_object is not None + assert str(ip_object) == ALLOWED_IP + + +def test_resolve_yookassa_ip_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False) + + candidates = [ALLOWED_IP, "198.51.100.10"] + + ip_object = resolve_yookassa_ip(candidates, remote="198.51.100.20") + + assert ip_object is not None + assert str(ip_object) == ALLOWED_IP + + +def test_resolve_yookassa_ip_returns_none_when_no_candidates() -> None: + assert resolve_yookassa_ip([], remote=None) is None async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> web.Response: + body = json.dumps(payload, ensure_ascii=False) return await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=_json_bytes(payload), + data=body.encode("utf-8"), headers=_build_headers(**headers), ) @@ -106,7 +174,7 @@ async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyP payload = {"event": "payment.canceled", "object": {"id": "yk_1"}} response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=_json_bytes(payload), + data=json.dumps(payload).encode("utf-8"), headers=_build_headers(), ) @@ -114,82 +182,3 @@ async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyP assert status == 200 process_mock.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_handle_webhook_rejects_empty_body(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_get_db(monkeypatch) - - process_mock = AsyncMock(return_value=True) - service = SimpleNamespace(process_yookassa_webhook=process_mock) - - app = create_yookassa_webhook_app(service) - async with TestClient(TestServer(app)) as client: - response = await client.post( - settings.YOOKASSA_WEBHOOK_PATH, - data=b"", - headers=_build_headers(), - ) - - assert response.status == 400 - process_mock.assert_not_called() - - -@pytest.mark.asyncio -async def test_handle_webhook_rejects_invalid_json(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_get_db(monkeypatch) - - process_mock = AsyncMock(return_value=True) - service = SimpleNamespace(process_yookassa_webhook=process_mock) - - app = create_yookassa_webhook_app(service) - async with TestClient(TestServer(app)) as client: - response = await client.post( - settings.YOOKASSA_WEBHOOK_PATH, - data=b"{not-json}", - headers=_build_headers(), - ) - - assert response.status == 400 - process_mock.assert_not_called() - - -@pytest.mark.asyncio -async def test_handle_webhook_requires_event(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_get_db(monkeypatch) - - process_mock = AsyncMock(return_value=True) - service = SimpleNamespace(process_yookassa_webhook=process_mock) - - app = create_yookassa_webhook_app(service) - async with TestClient(TestServer(app)) as client: - response = await client.post( - settings.YOOKASSA_WEBHOOK_PATH, - data=_json_bytes({}), - headers=_build_headers(), - ) - - assert response.status == 400 - process_mock.assert_not_called() - - -@pytest.mark.asyncio -async def test_handle_webhook_ignores_unhandled_event(monkeypatch: pytest.MonkeyPatch) -> None: - _patch_get_db(monkeypatch) - - process_mock = AsyncMock(return_value=True) - service = SimpleNamespace(process_yookassa_webhook=process_mock) - - app = create_yookassa_webhook_app(service) - async with TestClient(TestServer(app)) as client: - response = await client.post( - settings.YOOKASSA_WEBHOOK_PATH, - data=_json_bytes({"event": "unknown.event"}), - headers=_build_headers(), - ) - status = response.status - text = await response.text() - - assert status == 200 - assert text == "OK" - process_mock.assert_not_called() diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py index d48072c2..22fe3b2f 100644 --- a/tests/webserver/test_payments.py +++ b/tests/webserver/test_payments.py @@ -26,6 +26,7 @@ def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False) monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False) monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False) monkeypatch.setattr(settings, "WEBHOOK_URL", "http://test", raising=False) @@ -40,6 +41,7 @@ def _build_request( path: str, body: bytes, headers: dict[str, str], + client_ip: str | None = "185.71.76.1", ) -> Request: scope = { "type": "http", @@ -49,6 +51,9 @@ def _build_request( "headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()], } + if client_ip is not None: + scope["client"] = (client_ip, 12345) + async def receive() -> dict: return {"type": "http.request", "body": body, "more_body": False} @@ -93,6 +98,235 @@ async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: process_mock.assert_awaited_once() +@pytest.mark.anyio +async def test_yookassa_unknown_ip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={}, + client_ip=None, + ) + + response = await route.endpoint(request) + + assert response.status_code == 403 + payload = json.loads(response.body.decode("utf-8")) + assert payload["reason"] == "unknown_ip" + service.process_yookassa_webhook.assert_not_awaited() + + +@pytest.mark.anyio +async def test_yookassa_forbidden_ip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={}, + client_ip="8.8.8.8", + ) + + response = await route.endpoint(request) + + assert response.status_code == 403 + payload = json.loads(response.body.decode("utf-8")) + assert payload["reason"] == "forbidden_ip" + assert payload["ip"] == "8.8.8.8" + service.process_yookassa_webhook.assert_not_awaited() + + +@pytest.mark.anyio +async def test_yookassa_forbidden_ip_ignores_spoofed_header(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={"X-Forwarded-For": "185.71.76.10"}, + client_ip="8.8.8.8", + ) + + response = await route.endpoint(request) + + assert response.status_code == 403 + payload = json.loads(response.body.decode("utf-8")) + assert payload["reason"] == "forbidden_ip" + assert payload["ip"] == "8.8.8.8" + service.process_yookassa_webhook.assert_not_awaited() + + +@pytest.mark.anyio +async def test_yookassa_forbidden_ip_ignores_spoofed_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={"X-Forwarded-For": "185.71.76.10, 8.8.8.8"}, + client_ip="10.0.0.5", + ) + + response = await route.endpoint(request) + + assert response.status_code == 403 + payload = json.loads(response.body.decode("utf-8")) + assert payload["reason"] == "forbidden_ip" + assert payload["ip"] == "8.8.8.8" + service.process_yookassa_webhook.assert_not_awaited() + + +@pytest.mark.anyio +async def test_yookassa_allowed_ip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + async def fake_get_db(): + yield SimpleNamespace() + + monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + + process_mock = AsyncMock(return_value=True) + service = SimpleNamespace(process_yookassa_webhook=process_mock) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={}, + client_ip="185.71.76.10", + ) + + response = await route.endpoint(request) + + assert response.status_code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["status"] == "ok" + process_mock.assert_awaited_once() + + +@pytest.mark.anyio +async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + async def fake_get_db(): + yield SimpleNamespace() + + monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + + process_mock = AsyncMock(return_value=True) + service = SimpleNamespace(process_yookassa_webhook=process_mock) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={"X-Forwarded-For": "185.71.76.10"}, + client_ip="10.0.0.5", + ) + + response = await route.endpoint(request) + + assert response.status_code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["status"] == "ok" + process_mock.assert_awaited_once() + + +@pytest.mark.anyio +async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False) + + async def fake_get_db(): + yield SimpleNamespace() + + monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + + process_mock = AsyncMock(return_value=True) + service = SimpleNamespace(process_yookassa_webhook=process_mock) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={"X-Forwarded-For": "185.71.76.10, 203.0.113.10"}, + client_ip="10.0.0.5", + ) + + response = await route.endpoint(request) + + assert response.status_code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["status"] == "ok" + process_mock.assert_awaited_once() + + +@pytest.mark.anyio +async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False) + + async def fake_get_db(): + yield SimpleNamespace() + + monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + + process_mock = AsyncMock(return_value=True) + service = SimpleNamespace(process_yookassa_webhook=process_mock) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={"X-Forwarded-For": "185.71.76.10, 198.51.100.10"}, + client_ip="198.51.100.20", + ) + + response = await route.endpoint(request) + + assert response.status_code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["status"] == "ok" + process_mock.assert_awaited_once() + + @pytest.mark.anyio async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)