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:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user