From bf1b8315a8fe500fe428515cd17c31aa34313c88 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 11:27:13 +0300 Subject: [PATCH 1/3] Revert "Verify YooKassa webhooks against API" --- app/services/payment/yookassa.py | 69 +++++++------------ .../services/test_payment_service_webhooks.py | 60 ---------------- 2 files changed, 24 insertions(+), 105 deletions(-) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index f51d6179..45bacc04 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -786,52 +786,31 @@ class YooKassaPaymentMixin: logger.warning("Webhook без payment id: %s", event) return False - if not getattr(self, "yookassa_service", None): - logger.error( - "Не настроена служба YooKassa для верификации платежей, webhook %s отклонён", - yookassa_payment_id, - ) - return False + remote_data: Optional[Dict[str, Any]] = None + if getattr(self, "yookassa_service", None): + try: + remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] + yookassa_payment_id + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось запросить актуальный статус платежа YooKassa %s: %s", + yookassa_payment_id, + error, + exc_info=True, + ) - try: - remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] - yookassa_payment_id - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось запросить актуальный статус платежа YooKassa %s: %s", - yookassa_payment_id, - error, - exc_info=True, - ) - return False - - if not remote_data: - logger.error( - "YooKassa API вернул пустой ответ при проверке платежа %s", - yookassa_payment_id, - ) - return False - - remote_payment_id = remote_data.get("id") - if remote_payment_id and remote_payment_id != yookassa_payment_id: - logger.error( - "Несовпадение идентификаторов платежа при проверке YooKassa: webhook=%s, api=%s", - yookassa_payment_id, - remote_payment_id, - ) - return False - - previous_status = event_object.get("status") - event_object = self._merge_remote_yookassa_payload(event_object, remote_data) - if previous_status and event_object.get("status") != previous_status: - logger.info( - "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", - yookassa_payment_id, - previous_status, - event_object.get("status"), - ) - event["object"] = event_object + if remote_data: + previous_status = event_object.get("status") + event_object = self._merge_remote_yookassa_payload(event_object, remote_data) + if previous_status and event_object.get("status") != previous_status: + logger.info( + "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", + yookassa_payment_id, + previous_status, + event_object.get("status"), + ) + event["object"] = event_object payment_module = import_module("app.services.payment_service") diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 6fbd83e4..7285835b 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -564,17 +564,6 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) service.build_topup_success_keyboard = AsyncMock(return_value=None) - remote_payload = { - "id": "yk_123", - "status": "succeeded", - "paid": True, - "payment_method": {"type": "bank_card"}, - } - - service.yookassa_service = SimpleNamespace( - get_payment_info=AsyncMock(return_value=remote_payload) - ) - payload = { "object": { "id": "yk_123", @@ -587,7 +576,6 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) result = await service.process_yookassa_webhook(fake_session, payload) assert result is True - service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_123") assert transactions and transactions[0]["amount_kopeks"] == 10000 assert payment.transaction_id == 999 assert payment.is_paid is True @@ -761,24 +749,6 @@ async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest get_info_mock.assert_awaited_once_with("yk_cancel") -@pytest.mark.anyio("asyncio") -async def test_process_yookassa_webhook_api_failure(monkeypatch: pytest.MonkeyPatch) -> None: - service = _make_service(DummyBot()) - db = FakeSession() - - service.yookassa_service = SimpleNamespace( - get_payment_info=AsyncMock(return_value=None) - ) - - result = await service.process_yookassa_webhook( - db, - {"object": {"id": "yk_fail", "status": "succeeded", "paid": True}}, - ) - - assert result is False - service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_fail") - - @pytest.mark.anyio("asyncio") async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch: pytest.MonkeyPatch, @@ -888,24 +858,6 @@ async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) service.build_topup_success_keyboard = AsyncMock(return_value=None) - remote_payload = { - "id": "yk_456", - "status": "succeeded", - "paid": True, - "amount": {"value": "150.00", "currency": "RUB"}, - "metadata": {"user_id": "21", "payment_purpose": "balance_topup"}, - "description": "Пополнение", - "payment_method": {"type": "bank_card"}, - "created_at": "2024-01-02T12:00:00Z", - "captured_at": "2024-01-02T12:05:00Z", - "confirmation": {"confirmation_url": "https://pay.example"}, - "refundable": False, - } - - service.yookassa_service = SimpleNamespace( - get_payment_info=AsyncMock(return_value=remote_payload) - ) - payload = { "object": { "id": "yk_456", @@ -932,7 +884,6 @@ async def test_process_yookassa_webhook_restores_missing_payment( assert user.balance_kopeks == 15000 assert bot.sent_messages assert admin_calls - service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_456") @pytest.mark.anyio("asyncio") @@ -950,22 +901,11 @@ async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.Mon monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock) monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock) - service.yookassa_service = SimpleNamespace( - get_payment_info=AsyncMock( - return_value={ - "id": "yk_missing", - "status": "succeeded", - "paid": True, - } - ) - ) - payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}} result = await service.process_yookassa_webhook(db, payload) assert result is False - service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_missing") create_mock.assert_not_awaited() update_mock.assert_not_awaited() From 984870c78c1a33200d973053e6519e45af1f7fee Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 11:27:37 +0300 Subject: [PATCH 2/3] Remove YooKassa IP filtering from webhooks --- app/external/yookassa_webhook.py | 181 +------------- app/services/payment/yookassa.py | 69 ++++-- app/webserver/payments.py | 30 --- tests/external/test_yookassa_webhook.py | 154 ++++++------ .../services/test_payment_service_webhooks.py | 60 +++++ tests/webserver/test_payments.py | 234 ------------------ 6 files changed, 190 insertions(+), 538 deletions(-) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 8ddd1d53..237f6ee6 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -1,38 +1,17 @@ import asyncio -import logging import json -from ipaddress import ( - IPv4Address, - IPv4Network, - IPv6Address, - IPv6Network, - ip_address, - ip_network, -) -from typing import Iterable, Optional, Dict, Any, List, Union, Tuple +import logging +from typing import Any, Dict + from aiohttp import web from app.config import settings -from app.services.payment_service import PaymentService from app.database.database import get_db +from app.services.payment_service import PaymentService 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", @@ -40,132 +19,6 @@ 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): @@ -177,32 +30,6 @@ 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/services/payment/yookassa.py b/app/services/payment/yookassa.py index 45bacc04..f51d6179 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -786,31 +786,52 @@ class YooKassaPaymentMixin: logger.warning("Webhook без payment id: %s", event) return False - remote_data: Optional[Dict[str, Any]] = None - if getattr(self, "yookassa_service", None): - try: - remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] - yookassa_payment_id - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось запросить актуальный статус платежа YooKassa %s: %s", - yookassa_payment_id, - error, - exc_info=True, - ) + if not getattr(self, "yookassa_service", None): + logger.error( + "Не настроена служба YooKassa для верификации платежей, webhook %s отклонён", + yookassa_payment_id, + ) + return False - if remote_data: - previous_status = event_object.get("status") - event_object = self._merge_remote_yookassa_payload(event_object, remote_data) - if previous_status and event_object.get("status") != previous_status: - logger.info( - "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", - yookassa_payment_id, - previous_status, - event_object.get("status"), - ) - event["object"] = event_object + try: + remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] + yookassa_payment_id + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось запросить актуальный статус платежа YooKassa %s: %s", + yookassa_payment_id, + error, + exc_info=True, + ) + return False + + if not remote_data: + logger.error( + "YooKassa API вернул пустой ответ при проверке платежа %s", + yookassa_payment_id, + ) + return False + + remote_payment_id = remote_data.get("id") + if remote_payment_id and remote_payment_id != yookassa_payment_id: + logger.error( + "Несовпадение идентификаторов платежа при проверке YooKassa: webhook=%s, api=%s", + yookassa_payment_id, + remote_payment_id, + ) + return False + + previous_status = event_object.get("status") + event_object = self._merge_remote_yookassa_payload(event_object, remote_data) + if previous_status and event_object.get("status") != previous_status: + logger.info( + "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", + yookassa_payment_id, + previous_status, + event_object.get("status"), + ) + event["object"] = event_object payment_module = import_module("app.services.payment_service") diff --git a/app/webserver/payments.py b/app/webserver/payments.py index e78a5514..5b352c9d 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -346,36 +346,6 @@ 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 6a210968..fa6534c0 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -7,13 +7,7 @@ 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, - resolve_yookassa_ip, -) - - -ALLOWED_IP = "185.71.76.10" +from app.external.yookassa_webhook import create_yookassa_webhook_app class DummyDB: @@ -25,79 +19,14 @@ 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", - "X-Forwarded-For": ALLOWED_IP, - } + headers = {"Content-Type": "application/json"} headers.update(overrides) return headers -@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( @@ -182,3 +111,82 @@ 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.dumps({}).encode("utf-8"), + 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.dumps({"event": "unknown.event"}).encode("utf-8"), + 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/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 7285835b..6fbd83e4 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -564,6 +564,17 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) service.build_topup_success_keyboard = AsyncMock(return_value=None) + remote_payload = { + "id": "yk_123", + "status": "succeeded", + "paid": True, + "payment_method": {"type": "bank_card"}, + } + + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock(return_value=remote_payload) + ) + payload = { "object": { "id": "yk_123", @@ -576,6 +587,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) result = await service.process_yookassa_webhook(fake_session, payload) assert result is True + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_123") assert transactions and transactions[0]["amount_kopeks"] == 10000 assert payment.transaction_id == 999 assert payment.is_paid is True @@ -749,6 +761,24 @@ async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest get_info_mock.assert_awaited_once_with("yk_cancel") +@pytest.mark.anyio("asyncio") +async def test_process_yookassa_webhook_api_failure(monkeypatch: pytest.MonkeyPatch) -> None: + service = _make_service(DummyBot()) + db = FakeSession() + + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock(return_value=None) + ) + + result = await service.process_yookassa_webhook( + db, + {"object": {"id": "yk_fail", "status": "succeeded", "paid": True}}, + ) + + assert result is False + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_fail") + + @pytest.mark.anyio("asyncio") async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch: pytest.MonkeyPatch, @@ -858,6 +888,24 @@ async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) service.build_topup_success_keyboard = AsyncMock(return_value=None) + remote_payload = { + "id": "yk_456", + "status": "succeeded", + "paid": True, + "amount": {"value": "150.00", "currency": "RUB"}, + "metadata": {"user_id": "21", "payment_purpose": "balance_topup"}, + "description": "Пополнение", + "payment_method": {"type": "bank_card"}, + "created_at": "2024-01-02T12:00:00Z", + "captured_at": "2024-01-02T12:05:00Z", + "confirmation": {"confirmation_url": "https://pay.example"}, + "refundable": False, + } + + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock(return_value=remote_payload) + ) + payload = { "object": { "id": "yk_456", @@ -884,6 +932,7 @@ async def test_process_yookassa_webhook_restores_missing_payment( assert user.balance_kopeks == 15000 assert bot.sent_messages assert admin_calls + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_456") @pytest.mark.anyio("asyncio") @@ -901,11 +950,22 @@ async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.Mon monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock) monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock) + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock( + return_value={ + "id": "yk_missing", + "status": "succeeded", + "paid": True, + } + ) + ) + payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}} result = await service.process_yookassa_webhook(db, payload) assert result is False + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_missing") create_mock.assert_not_awaited() update_mock.assert_not_awaited() diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py index 22fe3b2f..d48072c2 100644 --- a/tests/webserver/test_payments.py +++ b/tests/webserver/test_payments.py @@ -26,7 +26,6 @@ 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) @@ -41,7 +40,6 @@ def _build_request( path: str, body: bytes, headers: dict[str, str], - client_ip: str | None = "185.71.76.1", ) -> Request: scope = { "type": "http", @@ -51,9 +49,6 @@ 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} @@ -98,235 +93,6 @@ 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) From 62fc014bc39a8375bce4ff6d9c9bd8aa80c6504d Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 11:38:18 +0300 Subject: [PATCH 3/3] Restore json serialization helper for YooKassa webhook tests --- tests/external/test_yookassa_webhook.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index fa6534c0..35af5f57 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -27,11 +27,14 @@ def _build_headers(**overrides: str) -> dict[str, str]: return headers +def _json_bytes(payload: dict) -> bytes: + return json.dumps(payload, ensure_ascii=False).encode("utf-8") + + 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=body.encode("utf-8"), + data=_json_bytes(payload), headers=_build_headers(**headers), ) @@ -103,7 +106,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.dumps(payload).encode("utf-8"), + data=_json_bytes(payload), headers=_build_headers(), ) @@ -162,7 +165,7 @@ async def test_handle_webhook_requires_event(monkeypatch: pytest.MonkeyPatch) -> async with TestClient(TestServer(app)) as client: response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=json.dumps({}).encode("utf-8"), + data=_json_bytes({}), headers=_build_headers(), ) @@ -181,7 +184,7 @@ async def test_handle_webhook_ignores_unhandled_event(monkeypatch: pytest.Monkey async with TestClient(TestServer(app)) as client: response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=json.dumps({"event": "unknown.event"}).encode("utf-8"), + data=_json_bytes({"event": "unknown.event"}), headers=_build_headers(), ) status = response.status