import json from types import SimpleNamespace from unittest.mock import AsyncMock import pytest 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' class DummyDB: async def close(self) -> None: # pragma: no cover - simple stub pass @pytest.fixture(autouse=True) def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) 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, 'Cf-Connecting-Ip': ALLOWED_IP, } 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( settings.YOOKASSA_WEBHOOK_PATH, data=body.encode('utf-8'), headers=_build_headers(**headers), ) def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_get_db(): yield DummyDB() monkeypatch.setattr('app.external.yookassa_webhook.get_db', fake_get_db) @pytest.mark.asyncio async def test_handle_webhook_success(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: payload = {'event': 'payment.succeeded'} body = json.dumps(payload, ensure_ascii=False) response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, data=body.encode('utf-8'), headers=_build_headers(), ) status = response.status text = await response.text() assert status == 400 assert text == 'No payment id' process_mock.assert_not_awaited() @pytest.mark.asyncio async def test_handle_webhook_trusts_cf_connecting_ip(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: payload = {'event': 'payment.succeeded'} body = json.dumps(payload, ensure_ascii=False) headers = _build_headers() headers.pop('X-Forwarded-For') response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, data=body.encode('utf-8'), headers=headers, ) status = response.status text = await response.text() assert status == 400 assert text == 'No payment id' process_mock.assert_not_awaited() @pytest.mark.asyncio async def test_handle_webhook_with_optional_signature(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: payload = {'event': 'payment.succeeded'} body = json.dumps(payload, ensure_ascii=False) response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, data=body.encode('utf-8'), headers=_build_headers(Signature='test-signature'), ) status = response.status text = await response.text() assert status == 400 assert text == 'No payment id' process_mock.assert_not_awaited() @pytest.mark.asyncio async def test_handle_webhook_accepts_canceled_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: payload = {'event': 'payment.canceled', 'object': {'id': 'yk_1'}} response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, data=json.dumps(payload).encode('utf-8'), headers=_build_headers(), ) status = response.status assert status == 200 process_mock.assert_awaited_once()