mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-02 00:03:05 +00:00
Merge pull request #1767 from Fr1ngg/bedolaga/remove-webhook-secret-references-for-yukassa-n5vl0v
Handle YooKassa cancellations in FastAPI webhook
This commit is contained in:
@@ -242,7 +242,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
|
||||
@@ -238,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
|
||||
@@ -187,7 +187,6 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
|
||||
99
app/external/yookassa_webhook.py
vendored
99
app/external/yookassa_webhook.py
vendored
@@ -1,9 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from ipaddress import (
|
||||
IPv4Address,
|
||||
IPv4Network,
|
||||
@@ -36,6 +33,13 @@ YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
)
|
||||
|
||||
|
||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for value in values:
|
||||
@@ -163,72 +167,6 @@ def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
@@ -274,25 +212,8 @@ class YooKassaWebhookHandler:
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
if not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
return web.Response(status=401, text="Missing signature")
|
||||
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(
|
||||
body,
|
||||
signature,
|
||||
settings.YOOKASSA_WEBHOOK_SECRET,
|
||||
):
|
||||
logger.warning("❌ Неверная подпись YooKassa webhook")
|
||||
return web.Response(status=401, text="Invalid signature")
|
||||
elif signature:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
@@ -308,7 +229,7 @@ class YooKassaWebhookHandler:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
@@ -30,6 +30,66 @@ if TYPE_CHECKING:
|
||||
class YooKassaPaymentMixin:
|
||||
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
|
||||
|
||||
@staticmethod
|
||||
def _format_amount_value(value: Any) -> str:
|
||||
"""Форматирует сумму для хранения в webhook-объекте."""
|
||||
|
||||
try:
|
||||
quantized = Decimal(str(value)).quantize(Decimal("0.00"))
|
||||
return format(quantized, "f")
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def _merge_remote_yookassa_payload(
|
||||
cls,
|
||||
event_object: Dict[str, Any],
|
||||
remote_data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Объединяет локальные данные вебхука с ответом API YooKassa."""
|
||||
|
||||
merged: Dict[str, Any] = dict(event_object)
|
||||
|
||||
status = remote_data.get("status")
|
||||
if status:
|
||||
merged["status"] = status
|
||||
|
||||
if "paid" in remote_data:
|
||||
merged["paid"] = bool(remote_data.get("paid"))
|
||||
|
||||
if "refundable" in remote_data:
|
||||
merged["refundable"] = bool(remote_data.get("refundable"))
|
||||
|
||||
payment_method_type = remote_data.get("payment_method_type")
|
||||
if payment_method_type:
|
||||
payment_method = dict(merged.get("payment_method") or {})
|
||||
payment_method["type"] = payment_method_type
|
||||
merged["payment_method"] = payment_method
|
||||
|
||||
amount_value = remote_data.get("amount_value")
|
||||
amount_currency = remote_data.get("amount_currency")
|
||||
if amount_value is not None or amount_currency:
|
||||
merged_amount = dict(merged.get("amount") or {})
|
||||
if amount_value is not None:
|
||||
merged_amount["value"] = cls._format_amount_value(amount_value)
|
||||
if amount_currency:
|
||||
merged_amount["currency"] = str(amount_currency).upper()
|
||||
merged["amount"] = merged_amount
|
||||
|
||||
for datetime_field in ("captured_at", "created_at"):
|
||||
value = remote_data.get(datetime_field)
|
||||
if value:
|
||||
merged[datetime_field] = value
|
||||
|
||||
metadata = remote_data.get("metadata")
|
||||
if metadata:
|
||||
try:
|
||||
merged["metadata"] = dict(metadata) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
merged["metadata"] = metadata
|
||||
|
||||
return merged
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -726,6 +786,32 @@ 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 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")
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
|
||||
@@ -383,27 +383,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
body = body_bytes.decode("utf-8")
|
||||
|
||||
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
if not signature:
|
||||
logger.warning("⚠️ YooKassa webhook без подписи при настроенном секрете")
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "missing_signature"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not yookassa_webhook_module.YooKassaWebhookHandler.verify_webhook_signature(
|
||||
body,
|
||||
signature,
|
||||
settings.YOOKASSA_WEBHOOK_SECRET,
|
||||
):
|
||||
logger.warning("❌ Неверная подпись YooKassa webhook")
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
elif signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
@@ -420,7 +401,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if event_type not in {"payment.succeeded", "payment.waiting_for_capture"}:
|
||||
if event_type not in {
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
}:
|
||||
return JSONResponse({"status": "ok", "ignored": event_type})
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
|
||||
103
tests/external/test_yookassa_webhook.py
vendored
103
tests/external/test_yookassa_webhook.py
vendored
@@ -1,6 +1,3 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -22,22 +19,11 @@ ALLOWED_IP = "185.71.76.10"
|
||||
class DummyDB:
|
||||
async def close(self) -> None: # pragma: no cover - simple stub
|
||||
pass
|
||||
|
||||
|
||||
def _generate_signature(body: str, secret: str) -> str:
|
||||
payment_id = "test-payment"
|
||||
timestamp = "2024-01-01T00:00:00.000Z"
|
||||
payload = f"{payment_id}.{timestamp}.{body}".encode("utf-8")
|
||||
digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()
|
||||
return f"v1 {payment_id} {timestamp} {base64.b64encode(digest).decode('utf-8')}"
|
||||
|
||||
|
||||
@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_SECRET", "secret", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
||||
|
||||
@@ -129,45 +115,7 @@ def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
response = await _post_webhook(client, {"event": "payment.succeeded"})
|
||||
status = response.status
|
||||
body = await response.text()
|
||||
|
||||
assert status == 401
|
||||
assert body == "Missing signature"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
response = await _post_webhook(
|
||||
client,
|
||||
{"event": "payment.succeeded"},
|
||||
Signature="v1 test-payment 2024-01-01T00:00:00.000Z invalid",
|
||||
)
|
||||
status = response.status
|
||||
body = await response.text()
|
||||
|
||||
assert status == 401
|
||||
assert body == "Invalid signature"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_valid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
@@ -177,11 +125,10 @@ async def test_handle_webhook_valid_signature(monkeypatch: pytest.MonkeyPatch) -
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
signature = _generate_signature(body, settings.YOOKASSA_WEBHOOK_SECRET)
|
||||
response = await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=body.encode("utf-8"),
|
||||
headers=_build_headers(Signature=signature),
|
||||
headers=_build_headers(),
|
||||
)
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
@@ -189,3 +136,49 @@ async def test_handle_webhook_valid_signature(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@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 == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
@@ -584,6 +584,171 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
payment = SimpleNamespace(
|
||||
yookassa_payment_id="yk_789",
|
||||
user_id=42,
|
||||
amount_kopeks=20000,
|
||||
transaction_id=None,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
)
|
||||
|
||||
async def fake_get_payment(db, payment_id):
|
||||
return payment
|
||||
|
||||
async def fake_update(db, payment_id, status, is_paid, is_captured, captured_at, payment_method_type):
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.captured_at = captured_at
|
||||
payment.payment_method_type = payment_method_type
|
||||
return payment
|
||||
|
||||
async def fake_link(db, payment_id, transaction_id):
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
yk_module = ModuleType("app.database.crud.yookassa")
|
||||
yk_module.get_yookassa_payment_by_id = fake_get_payment
|
||||
yk_module.update_yookassa_payment_status = fake_update
|
||||
yk_module.link_yookassa_payment_to_transaction = fake_link
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=555, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=42,
|
||||
telegram_id=4200,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
return user
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
|
||||
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
|
||||
|
||||
admin_calls: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
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_789",
|
||||
"status": "succeeded",
|
||||
"paid": True,
|
||||
"amount_value": 200.0,
|
||||
"amount_currency": "rub",
|
||||
"payment_method_type": "bank_card",
|
||||
"refundable": True,
|
||||
}
|
||||
|
||||
get_info_mock = AsyncMock(return_value=remote_payload)
|
||||
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
|
||||
|
||||
payload = {
|
||||
"object": {
|
||||
"id": "yk_789",
|
||||
"status": "pending",
|
||||
"paid": False,
|
||||
}
|
||||
}
|
||||
|
||||
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert payment.status == "succeeded"
|
||||
assert payment.is_paid is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 20000
|
||||
assert payment.transaction_id == 555
|
||||
get_info_mock.assert_awaited_once_with("yk_789")
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
payment = SimpleNamespace(
|
||||
yookassa_payment_id="yk_cancel",
|
||||
user_id=77,
|
||||
amount_kopeks=5000,
|
||||
transaction_id=None,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
captured_at=None,
|
||||
payment_method_type=None,
|
||||
)
|
||||
|
||||
async def fake_get_payment(db, payment_id):
|
||||
return payment
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_yookassa_payment_by_id",
|
||||
fake_get_payment,
|
||||
)
|
||||
|
||||
get_info_mock = AsyncMock(
|
||||
return_value={
|
||||
"id": "yk_cancel",
|
||||
"status": "canceled",
|
||||
"paid": False,
|
||||
"amount_value": 50.0,
|
||||
"amount_currency": "RUB",
|
||||
}
|
||||
)
|
||||
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
|
||||
|
||||
payload = {
|
||||
"object": {
|
||||
"id": "yk_cancel",
|
||||
"status": "pending",
|
||||
"paid": False,
|
||||
}
|
||||
}
|
||||
|
||||
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert payment.status == "canceled"
|
||||
assert payment.is_paid is False
|
||||
assert fake_session.commits == 1
|
||||
assert fake_session.refreshed and fake_session.refreshed[0] is payment
|
||||
assert bot.sent_messages == []
|
||||
get_info_mock.assert_awaited_once_with("yk_cancel")
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_restores_missing_payment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -14,16 +12,6 @@ from app.webserver.payments import create_payment_router
|
||||
|
||||
class DummyBot:
|
||||
pass
|
||||
|
||||
|
||||
def _generate_yookassa_signature(body: str, secret: str) -> str:
|
||||
payment_id = "test-payment"
|
||||
timestamp = "2024-01-01T00:00:00.000Z"
|
||||
payload = f"{payment_id}.{timestamp}.{body}".encode("utf-8")
|
||||
digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()
|
||||
return f"v1 {payment_id} {timestamp} {base64.b64encode(digest).decode('utf-8')}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
|
||||
@@ -36,7 +24,6 @@ def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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_WEBHOOK_SECRET", None, 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)
|
||||
@@ -341,59 +328,8 @@ async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.Mon
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_missing_signature_when_secret_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", 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={},
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 401
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["reason"] == "missing_signature"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", 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={"Signature": "v1 test invalid"},
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 401
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["reason"] == "invalid_signature"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_valid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
@@ -409,11 +345,74 @@ async def test_yookassa_valid_signature(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
signature = _generate_yookassa_signature(body.decode("utf-8"), settings.YOOKASSA_WEBHOOK_SECRET)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=body,
|
||||
headers={"Signature": signature},
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user