diff --git a/.env.example b/.env.example index f70489ed..6387650a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 4a59898a..9b1452da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 ``` diff --git a/app/config.py b/app/config.py index 9937947f..0d63adaa 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 7120c4f5..8ddd1d53 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -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") diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 53542d53..45bacc04 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -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) diff --git a/app/webserver/payments.py b/app/webserver/payments.py index e893ccb7..e78a5514 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -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( diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index f10e9e12..6a210968 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -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() diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 61d66f5e..7285835b 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -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, diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py index 38e98e1f..22fe3b2f 100644 --- a/tests/webserver/test_payments.py +++ b/tests/webserver/test_payments.py @@ -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)