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:
Egor
2025-11-08 05:59:35 +03:00
committed by GitHub
9 changed files with 382 additions and 236 deletions

View File

@@ -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

View File

@@ -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
```

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()

View File

@@ -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,

View File

@@ -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)