Files
remnawave-bedolaga-telegram…/tests/webserver/test_payments.py
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- Add pyproject.toml with uv and ruff configuration
- Pin Python version to 3.13 via .python-version
- Add Makefile commands: lint, format, fix
- Apply ruff formatting to entire codebase
- Remove unused imports (base64 in yookassa/simple_subscription)
- Update .gitignore for new config files
2026-01-24 17:45:27 +03:00

507 lines
17 KiB
Python

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