From 2074e934ce4d32b56b4b3b4c42428797b0b3e038 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 10 Jun 2026 23:32:30 +0300 Subject: [PATCH] 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. --- tests/conftest.py | 2 + tests/test_build_services_wiring.py | 2 + tests/test_lava_provider.py | 506 ++++++++++++++++++++++++ tests/test_payment_provider_registry.py | 3 + tests/test_provider_labels.py | 1 + 5 files changed, 514 insertions(+) create mode 100644 tests/test_lava_provider.py diff --git a/tests/conftest.py b/tests/conftest.py index 5689dfe..02e1ebe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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_", diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py index 1ba47ea..4475e01 100644 --- a/tests/test_build_services_wiring.py +++ b/tests/test_build_services_wiring.py @@ -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) diff --git a/tests/test_lava_provider.py b/tests/test_lava_provider.py new file mode 100644 index 0000000..1a7663c --- /dev/null +++ b/tests/test_lava_provider.py @@ -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 + ) diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 2eca954..cc841d8 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -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", } diff --git a/tests/test_provider_labels.py b/tests/test_provider_labels.py index 73d8b08..c06d35e 100644 --- a/tests/test_provider_labels.py +++ b/tests/test_provider_labels.py @@ -26,6 +26,7 @@ _PROVIDERS_FROM_CALLERS = { "platega", "severpay", "wata", + "lava", "cryptopay", "paykilla", "telegram_stars",