import json from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from starlette.requests import Request from app.config import settings from app.webserver.payments import create_payment_router class DummyBot: pass @pytest.fixture(autouse=True) def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', False, raising=False) monkeypatch.setattr(settings, 'TRIBUTE_API_KEY', None, raising=False) monkeypatch.setattr(settings, 'TRIBUTE_WEBHOOK_PATH', '/tribute', raising=False) monkeypatch.setattr(settings, 'MULENPAY_WEBHOOK_PATH', '/mulen', raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', False, raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', None, raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_PATH', '/cryptobot', raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', None, raising=False) monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', False, raising=False) 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) def _get_route(router, path: str, method: str = 'POST'): for route in router.routes: if getattr(route, 'path', '') == path and method in getattr(route, 'methods', set()): return route raise AssertionError(f'Route {path} with method {method} not found') def _build_request( path: str, body: bytes, headers: dict[str, str], client_ip: str | None = '185.71.76.1', ) -> Request: scope = { 'type': 'http', 'asgi': {'version': '3.0'}, 'method': 'POST', 'path': path, '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} return Request(scope, receive) @pytest.mark.anyio async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', True, raising=False) process_mock = AsyncMock(return_value={'status': 'ok'}) class StubTributeService: def __init__(self, *_args, **_kwargs): pass async def process_webhook(self, payload: str): # type: ignore[override] return await process_mock(payload) class StubTributeAPI: @staticmethod def verify_webhook_signature(payload: str, signature: str) -> bool: return True monkeypatch.setattr('app.webserver.payments.TributeService', StubTributeService) monkeypatch.setattr('app.webserver.payments.TributeAPI', StubTributeAPI) router = create_payment_router(DummyBot(), SimpleNamespace()) assert router is not None route = _get_route(router, settings.TRIBUTE_WEBHOOK_PATH) request = _build_request( settings.TRIBUTE_WEBHOOK_PATH, body=json.dumps({'event': 'payment'}).encode('utf-8'), headers={'trbt-signature': 'sig'}, ) response = await route.endpoint(request) assert response.status_code == 200 assert json.loads(response.body.decode('utf-8'))['status'] == 'ok' 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_cf_connecting_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={'Cf-Connecting-Ip': '185.71.76.10'}, client_ip='172.64.223.133', ) 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) 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) payload = {'event': 'payment.succeeded'} body = json.dumps(payload).encode('utf-8') request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, body=body, headers={}, ) 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_cancellation(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) payload = {'event': 'payment.canceled'} body = json.dumps(payload).encode('utf-8') request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, body=body, headers={}, ) 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_with_signature(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) payload = {'event': 'payment.succeeded'} body = json.dumps(payload).encode('utf-8') request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, body=body, headers={'Signature': 'dummy'}, ) 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_cryptobot_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False) router = create_payment_router(DummyBot(), SimpleNamespace()) assert router is not None route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH) request = _build_request( settings.CRYPTOBOT_WEBHOOK_PATH, body=json.dumps({'test': 'value'}).encode('utf-8'), headers={}, ) response = await route.endpoint(request) assert response.status_code == 401 payload = json.loads(response.body.decode('utf-8')) assert payload['reason'] == 'missing_signature' @pytest.mark.anyio async def test_cryptobot_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False) monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False) class StubCryptoBotService: @staticmethod def verify_webhook_signature(body: str, signature: str) -> bool: return False monkeypatch.setattr('app.external.cryptobot.CryptoBotService', StubCryptoBotService) router = create_payment_router(DummyBot(), SimpleNamespace()) assert router is not None route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH) request = _build_request( settings.CRYPTOBOT_WEBHOOK_PATH, body=json.dumps({'test': 'value'}).encode('utf-8'), headers={'Crypto-Pay-API-Signature': 'sig'}, ) response = await route.endpoint(request) assert response.status_code == 401