fix: harden hwid provider payment edge cases

This commit is contained in:
3252a8
2026-05-27 18:31:14 +03:00
parent f2fc335221
commit 75586d8883
16 changed files with 444 additions and 25 deletions
+45
View File
@@ -1,6 +1,10 @@
import asyncio
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
from bot.payment_providers import (
@@ -16,8 +20,11 @@ from bot.payment_providers import (
resolve_provider_presentation,
)
from bot.payment_providers.shared import (
PaymentCallbackParts,
format_number_for_payload,
payment_record_amounts,
payment_units_for_activation,
quote_hwid_callback_parts,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
@@ -375,6 +382,13 @@ def test_common_sale_mode_helpers_cover_provider_payment_records():
assert hwid.tariff_key == "vip"
assert hwid.hwid_devices_sale
payment = SimpleNamespace(
purchased_gb=None,
purchased_hwid_devices=3,
subscription_duration_months=None,
)
assert payment_units_for_activation(payment, "hwid_devices@vip") == 3
def test_yookassa_hwid_webapp_metadata_uses_device_count_for_activation():
(
@@ -395,3 +409,34 @@ def test_yookassa_hwid_webapp_metadata_uses_device_count_for_activation():
assert hwid_devices_count == 3
assert months_for_activation == 3
assert traffic_gb_for_activation is None
def test_hwid_callback_quote_rejects_fractional_device_count():
subscription_service = SimpleNamespace(quote_hwid_device_topup=AsyncMock())
quoted_parts, quote = asyncio.run(
quote_hwid_callback_parts(
session=AsyncMock(),
user_id=42,
parts=PaymentCallbackParts(
months=1.9,
price=50,
sale_mode="hwid_devices@vip",
),
subscription_service=subscription_service,
)
)
assert quoted_parts is None
assert quote is None
subscription_service.quote_hwid_device_topup.assert_not_awaited()
def test_yookassa_hwid_metadata_rejects_fractional_device_count():
with pytest.raises(ValueError):
_resolve_yookassa_activation_amounts(
sale_mode_base="hwid_devices",
subscription_months_raw="0",
traffic_gb_raw=None,
hwid_devices_raw="1.9",
)
+179
View File
@@ -0,0 +1,179 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from bot.payment_providers import cryptopay, severpay, stars
class _FakeSession:
def __call__(self):
return self
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
pass
async def rollback(self):
pass
class _FakeJsonRequest:
def __init__(self, payload):
self._payload = payload
async def json(self):
return self._payload
def test_cryptopay_duplicate_success_webhook_does_not_finalize_again(monkeypatch):
session = _FakeSession()
payment = SimpleNamespace(payment_id=77, status="succeeded")
update = SimpleNamespace(
payload=SimpleNamespace(
payload=json.dumps(
{
"user_id": "42",
"subscription_months": "0",
"payment_db_id": "77",
"sale_mode": "hwid_devices@standard",
}
),
invoice_id=9001,
amount=100,
asset="USDT",
)
)
app = {
"async_session_factory": session,
"bot": SimpleNamespace(),
"settings": SimpleNamespace(traffic_sale_mode=False, DEFAULT_CURRENCY_SYMBOL="RUB"),
"i18n": SimpleNamespace(),
"subscription_service": SimpleNamespace(),
"referral_service": SimpleNamespace(),
}
monkeypatch.setattr(
cryptopay.payment_dal,
"get_payment_by_db_id",
AsyncMock(return_value=payment),
)
monkeypatch.setattr(
cryptopay.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("duplicate webhook must not update payment")),
)
monkeypatch.setattr(
cryptopay,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("duplicate webhook must not finalize")),
)
service = SimpleNamespace(settings=SimpleNamespace(traffic_sale_mode=False))
asyncio.run(cryptopay.CryptoPayService._invoice_paid_handler(service, update, app))
def test_severpay_duplicate_success_webhook_does_not_finalize_again(monkeypatch):
session = _FakeSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="succeeded",
sale_mode="hwid_devices@standard",
purchased_hwid_devices=3,
purchased_gb=None,
subscription_duration_months=None,
amount=150.0,
currency="RUB",
user=None,
)
async def lookup_payment(_session, *, order_id_raw=None, provider_payment_id=None):
assert _session is session
assert order_id_raw == "88"
assert provider_payment_id == "sev-1"
return payment
monkeypatch.setattr(severpay, "lookup_payment_by_order_or_provider_id", lookup_payment)
monkeypatch.setattr(
severpay.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("duplicate webhook must not update payment")),
)
monkeypatch.setattr(
severpay,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("duplicate webhook must not finalize")),
)
service = SimpleNamespace(
configured=True,
_validate_signature=lambda _payload: True,
async_session_factory=session,
settings=SimpleNamespace(traffic_sale_mode=False),
bot=SimpleNamespace(),
i18n=SimpleNamespace(),
subscription_service=SimpleNamespace(),
referral_service=SimpleNamespace(),
)
response = asyncio.run(
severpay.SeverPayService.webhook_route(
service,
_FakeJsonRequest(
{
"type": "payin",
"data": {
"id": "sev-1",
"order_id": "88",
"status": "success",
},
}
),
)
)
assert response.status == 200
def test_stars_duplicate_success_message_does_not_finalize_again(monkeypatch):
session = _FakeSession()
payment = SimpleNamespace(payment_id=99, status="succeeded")
message = SimpleNamespace(
successful_payment=SimpleNamespace(provider_payment_charge_id="stars-charge-1"),
from_user=SimpleNamespace(id=42),
)
monkeypatch.setattr(
stars.payment_dal,
"get_payment_by_db_id",
AsyncMock(return_value=payment),
)
monkeypatch.setattr(
stars.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("duplicate stars payment must not update")),
)
monkeypatch.setattr(
stars,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("duplicate stars payment must not finalize")),
)
service = SimpleNamespace()
asyncio.run(
stars.StarsService.process_successful_payment(
service,
session=session,
message=message,
payment_db_id=99,
months=3,
stars_amount=150,
i18n_data={},
sale_mode="hwid_devices@standard",
)
)
+59
View File
@@ -46,6 +46,7 @@ def _payment(**overrides):
"amount": 100.0,
"provider_payment_id": "link-id",
"purchased_gb": None,
"purchased_hwid_devices": None,
"subscription_duration_months": 1,
"sale_mode": "subscription",
"user": None,
@@ -274,6 +275,64 @@ def test_wata_refresh_finds_paid_transaction_by_order_id_and_finalizes(monkeypat
assert session.commits == 1
def test_wata_hwid_payment_finalizes_purchased_device_count(monkeypatch):
session = _FakeSession()
payment = _payment(
provider="wata",
provider_payment_id="link-id",
sale_mode="hwid_devices@standard",
subscription_duration_months=None,
purchased_hwid_devices=3,
)
finalized = []
service = _service(session)
async def search_transactions(*, order_id=None, payment_link_id=None, status=None, limit=5):
return True, {
"items": [
{
"id": "tx-paid",
"status": "Paid",
"orderId": "465",
"amount": 100,
"currency": "RUB",
"paymentLinkId": "link-id",
}
]
}
async def get_payment_by_db_id(_session, payment_id):
assert payment_id == 465
return payment
async def update_provider_payment_and_status(
_session,
payment_id,
provider_payment_id,
status,
):
payment.provider_payment_id = provider_payment_id
payment.status = status
async def finalize_successful_payment(request):
finalized.append((request.months, request.traffic_amount, request.sale_mode))
return SimpleNamespace()
service.search_transactions = search_transactions
monkeypatch.setattr(wata.payment_dal, "get_payment_by_db_id", get_payment_by_db_id)
monkeypatch.setattr(
wata.payment_dal,
"update_provider_payment_and_status",
update_provider_payment_and_status,
)
monkeypatch.setattr(wata, "finalize_successful_payment", finalize_successful_payment)
result = asyncio.run(service.refresh_payment_status(session, payment))
assert result is payment
assert finalized == [(3, 3.0, "hwid_devices@standard")]
def test_try_reuse_pending_link_returns_url_for_opened_link():
service = _service(_FakeSession())
payment = _payment(provider_payment_id="link-id")
+58
View File
@@ -196,3 +196,61 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
self.assertEqual(payload["price"], 25)
subscription_service.quote_hwid_device_topup.assert_awaited_once()
create_payment.assert_awaited_once()
async def test_create_payment_route_rejects_fractional_hwid_device_count(self):
tariff = SimpleNamespace(
key="standard",
billing_model="period",
enabled_periods=[1],
hwid_device_packages=SimpleNamespace(
rub=[SimpleNamespace(count=1)],
stars=[],
),
)
settings = SimpleNamespace(
traffic_sale_mode=False,
tariffs_config=SimpleNamespace(require=lambda key: tariff),
DEFAULT_LANGUAGE="en",
DEFAULT_CURRENCY_SYMBOL="RUB",
)
subscription_service = SimpleNamespace(quote_hwid_device_topup=AsyncMock())
request = SimpleNamespace(
app={
"settings": settings,
"async_session_factory": _SessionFactory(),
"subscription_service": subscription_service,
}
)
with (
patch.object(billing_module, "_require_user_id", return_value=42),
patch.object(
billing_module,
"_enforce_webapp_rate_limit",
AsyncMock(return_value=None),
),
patch.object(
billing_module,
"_read_json",
AsyncMock(
return_value={
"method": "yookassa",
"months": 1.9,
"device_count": 1.9,
"tariff_key": "standard",
"sale_mode": "hwid_devices",
}
),
),
patch.object(
billing_module,
"_get_cached_webapp_settings",
return_value={"subscription_options": {}, "stars_subscription_options": {}},
),
):
response = await billing_module.create_payment_route(request)
self.assertEqual(response.status, 400)
payload = json.loads(response.text)
self.assertEqual(payload["error"], "invalid_plan")
subscription_service.quote_hwid_device_topup.assert_not_awaited()