test(payments): cover LAVA provider contract

Pin the LAVA integration contract: raw-body HMAC signature in the
Signature header (never a body-embedded field or sorted re-encode),
tolerant webhook signature verification, duplicate-success and
amount-mismatch webhook handling, pending invoice reuse rules, and
RUB-only invoice currency. Extend the registry, wiring, label, and
env-isolation suites with the new provider.
This commit is contained in:
3252a8
2026-06-10 23:32:30 +03:00
parent bd1cfd1d63
commit 2074e934ce
5 changed files with 514 additions and 0 deletions
+2
View File
@@ -29,6 +29,7 @@ def _isolate_provider_env(monkeypatch):
"SEVERPAY_",
"WATA_",
"HELEKET_",
"LAVA_",
"CRYPTOPAY_",
"YOOKASSA_",
"PAYMENT_FREEKASSA_",
@@ -36,6 +37,7 @@ def _isolate_provider_env(monkeypatch):
"PAYMENT_SEVERPAY_",
"PAYMENT_WATA_",
"PAYMENT_HELEKET_",
"PAYMENT_LAVA_",
"PAYMENT_CRYPTOPAY_",
"PAYMENT_YOOKASSA_",
"PAYMENT_STARS_",
+2
View File
@@ -35,6 +35,7 @@ _PROVIDER_ENV_PREFIXES = (
"SEVERPAY_",
"WATA_",
"HELEKET_",
"LAVA_",
"CRYPTOPAY_",
"YOOKASSA_",
"STARS_",
@@ -176,6 +177,7 @@ class BuildServicesWiringTests(unittest.TestCase):
"wata_service",
"heleket_service",
"paykilla_service",
"lava_service",
}
self.assertEqual(set(services), expected_keys)
+506
View File
@@ -0,0 +1,506 @@
"""Contract tests for the LAVA Business provider.
The LAVA API accepts exactly one signature scheme for outgoing requests:
HMAC-SHA256 over the raw body bytes in the ``Signature`` HTTP header. A
body-embedded ``signature`` field (legacy PHP SDK style) is rejected with
401, so these tests pin the header form. Webhook verification is tolerant:
LAVA shops sign either the raw body or a sorted-keys re-serialization.
"""
import asyncio
import hashlib
import hmac
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from bot.payment_providers import lava
from bot.payment_providers.lava import LavaConfig, LavaService
def _hmac_hex(message: bytes, key: str) -> str:
return hmac.new(key.encode("utf-8"), message, hashlib.sha256).hexdigest()
def _make_service(**config_overrides) -> LavaService:
config_values = {
"ENABLED": True,
"SHOP_ID": "shop-xyz",
"SECRET_KEY": "outgoing-secret",
"WEBHOOK_SECRET": "webhook-secret",
}
config_values.update(config_overrides)
service = object.__new__(LavaService)
service.config = LavaConfig(**config_values)
service.settings = SimpleNamespace(
DEFAULT_CURRENCY_SYMBOL="RUB",
WEBHOOK_BASE_URL="https://bot.example.com",
PAYMENT_REQUEST_TIMEOUT_SECONDS=30,
)
service._default_return_url = "testbot"
return service
class _FakeResponse:
def __init__(self, status=200, payload=None):
self.status = status
self._payload = (
payload
if payload is not None
else {"status": "success", "data": {"id": "inv-1", "url": "https://pay.lava.ru/x"}}
)
async def text(self):
return json.dumps(self._payload)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def _capture_session(captured, response=None):
session = SimpleNamespace()
def post(url, data=None, headers=None):
captured["url"] = url
captured["data"] = data
captured["headers"] = headers
return response or _FakeResponse()
session.post = post
return session
class _FakeWebhookRequest:
def __init__(self, payload, signature=None, secret="webhook-secret"):
self._body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self.headers = {
"Authorization": signature if signature is not None else _hmac_hex(self._body, secret)
}
async def read(self):
return self._body
class _FakeDbSession:
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
# ---------------------------------------------------------------------------
# Outgoing request contract
# ---------------------------------------------------------------------------
def test_create_payment_signs_raw_body_in_signature_header(monkeypatch):
service = _make_service()
captured = {}
monkeypatch.setattr(service, "_get_session", AsyncMock(return_value=_capture_session(captured)))
success, data = asyncio.run(
service.create_payment(payment_db_id=77, amount=150.0, currency="RUB")
)
assert success
assert data == {"id": "inv-1", "url": "https://pay.lava.ru/x"}
assert captured["url"] == "https://api.lava.ru/business/invoice/create"
# Signature travels in the header and matches the exact bytes sent.
assert captured["headers"]["Signature"] == _hmac_hex(captured["data"], "outgoing-secret")
body = json.loads(captured["data"])
assert "signature" not in body
assert body["sum"] == 150.0
assert body["orderId"] == "77"
assert body["shopId"] == "shop-xyz"
assert body["hookUrl"] == "https://bot.example.com/webhook/lava"
def test_create_payment_keeps_payload_key_order(monkeypatch):
# A sorted-keys re-serialization would diverge from the signed raw body,
# so the wire format must preserve insertion order ("sum" before "hookUrl").
service = _make_service()
captured = {}
monkeypatch.setattr(service, "_get_session", AsyncMock(return_value=_capture_session(captured)))
asyncio.run(service.create_payment(payment_db_id=1, amount=10.0, currency="RUB"))
text = captured["data"].decode("utf-8")
assert text.find('"sum"') < text.find('"hookUrl"')
def test_create_payment_rejects_non_rub_currency(monkeypatch):
service = _make_service()
monkeypatch.setattr(
service,
"_get_session",
AsyncMock(side_effect=AssertionError("must not reach the API for unsupported currency")),
)
success, data = asyncio.run(
service.create_payment(payment_db_id=1, amount=10.0, currency="USD")
)
assert not success
assert data["message"] == "unsupported_currency"
def test_create_payment_surfaces_api_error(monkeypatch):
service = _make_service()
captured = {}
response = _FakeResponse(
status=401, payload={"status": "error", "error": "Invalid signature", "code": 401}
)
monkeypatch.setattr(
service,
"_get_session",
AsyncMock(return_value=_capture_session(captured, response=response)),
)
success, data = asyncio.run(
service.create_payment(payment_db_id=1, amount=10.0, currency="RUB")
)
assert not success
assert data["message"] == "Invalid signature"
def test_create_payment_passes_optional_invoice_fields(monkeypatch):
service = _make_service(LIFETIME_MINUTES=90, INCLUDE_SERVICES="card, sbp")
captured = {}
monkeypatch.setattr(service, "_get_session", AsyncMock(return_value=_capture_session(captured)))
asyncio.run(
service.create_payment(
payment_db_id=5, amount=10.0, currency="RUB", description="Subscription 1m"
)
)
body = json.loads(captured["data"])
assert body["expire"] == 90
assert body["comment"] == "Subscription 1m"
assert body["includeService"] == ["card", "sbp"]
def test_service_is_unconfigured_without_credentials():
assert not _make_service(SHOP_ID=None).configured
assert not _make_service(SECRET_KEY=None).configured
assert not _make_service(ENABLED=False).configured
assert _make_service().configured
assert _make_service(ENABLED=False, ADMIN_ONLY_ENABLED=True).configured
# ---------------------------------------------------------------------------
# Webhook signature verification
# ---------------------------------------------------------------------------
def test_webhook_signature_accepts_raw_body_hmac():
service = _make_service()
body = b'{"order_id":"1","status":"success"}'
assert service.verify_webhook_signature(body, _hmac_hex(body, "webhook-secret"))
def test_webhook_signature_accepts_canonical_sorted_json_hmac():
# Legacy PHP-SDK shops sign a sorted-keys re-serialization instead of the raw body.
service = _make_service()
body = b'{"status":"success","order_id":"1"}'
canonical = json.dumps(
{"order_id": "1", "status": "success"}, sort_keys=True, separators=(",", ":")
)
signature = _hmac_hex(canonical.encode("utf-8"), "webhook-secret")
assert service.verify_webhook_signature(body, signature)
def test_webhook_signature_rejects_wrong_or_empty_signature():
service = _make_service()
body = b'{"order_id":"1","status":"success"}'
assert not service.verify_webhook_signature(body, "deadbeef" * 8)
assert not service.verify_webhook_signature(body, "")
def test_webhook_signature_falls_back_to_secret_key():
service = _make_service(WEBHOOK_SECRET=None)
body = b'{"order_id":"1"}'
assert service.verify_webhook_signature(body, _hmac_hex(body, "outgoing-secret"))
def test_webhook_signature_fails_closed_without_any_secret():
service = _make_service(SECRET_KEY=None, WEBHOOK_SECRET=None)
body = b'{"order_id":"1"}'
assert not service.verify_webhook_signature(body, _hmac_hex(body, ""))
# ---------------------------------------------------------------------------
# Webhook route behaviour
# ---------------------------------------------------------------------------
def _webhook_service(session, payment, monkeypatch, **overrides):
monkeypatch.setattr(
lava,
"lookup_payment_by_order_or_provider_id",
AsyncMock(return_value=payment),
)
service = SimpleNamespace(
configured=True,
verify_webhook_signature=lambda _raw, _sig: True,
async_session_factory=session,
settings=SimpleNamespace(traffic_sale_mode=False),
bot=SimpleNamespace(),
i18n=SimpleNamespace(),
subscription_service=SimpleNamespace(),
referral_service=SimpleNamespace(),
)
for key, value in overrides.items():
setattr(service, key, value)
return service
def test_webhook_invalid_signature_is_rejected():
service = _make_service()
request = _FakeWebhookRequest({"order_id": "1", "status": "success"}, signature="f" * 64)
response = asyncio.run(service.webhook_route(request))
assert response.status == 403
def test_webhook_duplicate_success_does_not_finalize_again(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="succeeded",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
monkeypatch.setattr(
lava.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("duplicate webhook must not update payment")),
)
monkeypatch.setattr(
lava,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("duplicate webhook must not finalize")),
)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest({"invoice_id": "inv-1", "order_id": "88", "status": "success"}),
)
)
assert response.status == 200
def test_webhook_success_finalizes_payment(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="pending_lava",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
update_mock = AsyncMock()
finalize_mock = AsyncMock(return_value=SimpleNamespace())
monkeypatch.setattr(lava.payment_dal, "update_provider_payment_and_status", update_mock)
monkeypatch.setattr(lava, "finalize_successful_payment", finalize_mock)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest(
{"invoice_id": "inv-1", "order_id": "88", "status": "success", "amount": 150.0}
),
)
)
assert response.status == 200
update_mock.assert_awaited_once_with(session, 88, "inv-1", "succeeded")
finalize_mock.assert_awaited_once()
def test_webhook_success_with_amount_mismatch_is_rejected(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="pending_lava",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
monkeypatch.setattr(
lava.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("mismatched amount must not update payment")),
)
monkeypatch.setattr(
lava,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("mismatched amount must not finalize")),
)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest(
{"invoice_id": "inv-1", "order_id": "88", "status": "success", "amount": 9999}
),
)
)
assert response.status == 400
def test_webhook_failed_status_marks_payment_failed(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="pending_lava",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
update_mock = AsyncMock()
notify_mock = AsyncMock()
monkeypatch.setattr(lava.payment_dal, "update_provider_payment_and_status", update_mock)
monkeypatch.setattr(lava, "notify_user_payment_failed", notify_mock)
monkeypatch.setattr(
lava,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("failed webhook must not finalize")),
)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest({"invoice_id": "inv-1", "order_id": "88", "status": "expired"}),
)
)
assert response.status == 200
update_mock.assert_awaited_once_with(session, 88, "inv-1", "failed")
notify_mock.assert_awaited_once()
def test_webhook_unknown_payment_returns_404(monkeypatch):
session = _FakeDbSession()
service = _webhook_service(session, None, monkeypatch)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest({"invoice_id": "inv-x", "order_id": "404", "status": "success"}),
)
)
assert response.status == 404
# ---------------------------------------------------------------------------
# Pending payment reuse
# ---------------------------------------------------------------------------
def test_reuse_returns_url_for_pending_invoice(monkeypatch):
service = _make_service()
payment = SimpleNamespace(
payment_id=88,
provider_payment_id="inv-1",
provider_payment_url="https://pay.lava.ru/x",
)
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "inv-1", "order_id": "88", "status": "created"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) == "https://pay.lava.ru/x"
def test_reuse_rejects_terminal_or_foreign_invoices(monkeypatch):
service = _make_service()
payment = SimpleNamespace(
payment_id=88,
provider_payment_id="inv-1",
provider_payment_url="https://pay.lava.ru/x",
)
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "inv-1", "order_id": "88", "status": "expired"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) is None
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "other", "order_id": "88", "status": "created"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) is None
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "inv-1", "order_id": "99", "status": "created"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) is None
def test_reuse_requires_stored_url_and_id():
service = _make_service()
assert (
asyncio.run(
service.try_reuse_pending_payment(
SimpleNamespace(payment_id=1, provider_payment_id="", provider_payment_url="")
)
)
is None
)
+3
View File
@@ -66,6 +66,7 @@ _PROVIDER_MODULES = {
"wata": "WataService",
"heleket": "HeleketService",
"paykilla": "PaykillaService",
"lava": "LavaService",
}
@@ -128,6 +129,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
"cryptopay_service",
"heleket_service",
"paykilla_service",
"lava_service",
}
assert set(pending_statuses()) >= {
"pending",
@@ -140,6 +142,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
"pending_stars",
"pending_heleket",
"pending_paykilla",
"pending_lava",
}
+1
View File
@@ -26,6 +26,7 @@ _PROVIDERS_FROM_CALLERS = {
"platega",
"severpay",
"wata",
"lava",
"cryptopay",
"paykilla",
"telegram_stars",