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/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..35af5f57 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,84 +19,22 @@ 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 +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), ) @@ -174,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(), ) @@ -182,3 +114,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_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 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)