infrasynth-backend-kit/tests/test_billing/test_gateways.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

302 lines
12 KiB
Python

from unittest import mock
import pytest
from infrasynth.billing.gateways.base import (
BasePaymentGateway,
CheckoutSessionResult,
WebhookResult,
)
from infrasynth.billing.gateways.mercadopago import MercadoPagoGateway
from infrasynth.billing.gateways.stripe import StripeGateway
from infrasynth.billing.gateways.wompi import WompiGateway
pytestmark = pytest.mark.django_db
class _FakePlan:
slug = "pro"
name = "Pro"
price_amount = 100
price_currency = "USD"
external_id = "price_123"
class _FakeUser:
email = "user@example.com"
pk = 7
class _FakeSubscription:
external_id = "sub_123"
class _FakeInvoice:
external_id = "inv_123"
class TestBasePaymentGateway:
def test_is_abstract(self):
with pytest.raises(TypeError):
BasePaymentGateway()
def test_result_dataclasses(self):
result = CheckoutSessionResult("s1", "https://x", "cs")
assert result.session_id == "s1"
webhook = WebhookResult("evt", True, {})
assert webhook.is_handled is True
class TestStripeGateway:
def test_missing_credentials_raise(self):
gateway = StripeGateway({})
with pytest.raises(ValueError, match="API key"):
gateway.create_checkout_session(_FakePlan(), _FakeUser())
def test_create_checkout_session(self):
gateway = StripeGateway({"api_key": "sk_test"})
with mock.patch("stripe.checkout.Session.create") as create:
create.return_value = mock.Mock(id="cs_1", url="https://stripe.com/checkout", client_secret="cs_sec")
result = gateway.create_checkout_session(_FakePlan(), _FakeUser())
assert result.session_id == "cs_1"
assert result.checkout_url == "https://stripe.com/checkout"
create.assert_called_once()
kwargs = create.call_args.kwargs
assert kwargs["mode"] == "subscription"
assert kwargs["line_items"] == [{"price": "price_123", "quantity": 1}]
assert kwargs["customer_email"] == "user@example.com"
def test_create_checkout_without_external_id_raises(self):
gateway = StripeGateway({"api_key": "sk_test"})
plan = _FakePlan()
plan.external_id = ""
with pytest.raises(ValueError, match="external price ID"):
gateway.create_checkout_session(plan, _FakeUser())
def test_handle_webhook(self):
gateway = StripeGateway({"api_key": "sk_test", "webhook_secret": "whsec_1"})
with mock.patch("stripe.Webhook.construct_event") as construct:
construct.return_value = {
"type": "checkout.session.completed",
"data": {"object": {"id": "cs_1"}},
}
result = gateway.handle_webhook(
{"id": "cs_1"},
{"Stripe-Signature": "t=1,v1=sig"},
)
assert result.event_type == "checkout.session.completed"
assert result.is_handled is True
assert result.data["id"] == "cs_1"
def test_handle_webhook_without_secret_raises(self):
gateway = StripeGateway({"api_key": "sk_test"})
with pytest.raises(ValueError, match="webhook secret"):
gateway.handle_webhook({}, {})
def test_cancel_subscription(self):
gateway = StripeGateway({"api_key": "sk_test"})
with mock.patch("stripe.Subscription.cancel") as cancel:
assert gateway.cancel_subscription(_FakeSubscription()) is True
cancel.assert_called_once_with("sub_123")
def test_cancel_without_external_id(self):
gateway = StripeGateway({"api_key": "sk_test"})
subscription = _FakeSubscription()
subscription.external_id = ""
assert gateway.cancel_subscription(subscription) is False
def test_sync_subscription_maps_status(self):
gateway = StripeGateway({"api_key": "sk_test"})
with mock.patch("stripe.Subscription.retrieve") as retrieve:
retrieve.return_value = {
"status": "past_due",
"current_period_start": 1700000000,
"current_period_end": 1702592000,
"cancel_at_period_end": True,
"canceled_at": None,
"trial_end": None,
"metadata": {"x": "1"},
}
data = gateway.sync_subscription(_FakeSubscription())
assert data["status"] == "past_due"
assert data["cancel_at_period_end"] is True
assert data["current_period_end"] is not None
assert data["metadata"] == {"x": "1"}
def test_get_invoice(self):
gateway = StripeGateway({"api_key": "sk_test"})
with mock.patch("stripe.Invoice.retrieve") as retrieve:
retrieve.return_value = {
"id": "inv_1",
"status": "paid",
"amount_due": 12000,
"currency": "usd",
"paid_at": 1700000000,
"lines": {"data": [{"description": "Pro", "amount": 12000, "quantity": 1}]},
}
data = gateway.get_invoice(_FakeInvoice())
assert data["status"] == "paid"
assert data["amount"] == 120.0
assert data["currency"] == "USD"
assert data["line_items"][0]["description"] == "Pro"
def test_health_check(self):
assert StripeGateway({"api_key": "k"}).health_check() is True
assert StripeGateway({}).health_check() is False
class TestMercadoPagoGateway:
def test_missing_credentials_raise(self):
gateway = MercadoPagoGateway({})
with pytest.raises(ValueError, match="access token"):
gateway.create_checkout_session(_FakePlan(), _FakeUser())
def test_create_checkout_session(self):
gateway = MercadoPagoGateway({"access_token": "APP_USR-token"})
with mock.patch("mercadopago.SDK") as sdk_cls:
sdk_cls.return_value.preference().create.return_value = {
"status": 201,
"response": {
"id": "pref_1",
"init_point": "https://mercadopago.com/checkout",
},
}
result = gateway.create_checkout_session(_FakePlan(), _FakeUser())
assert result.session_id == "pref_1"
assert result.checkout_url == "https://mercadopago.com/checkout"
preference = sdk_cls.return_value.preference().create.call_args.args[0]
assert preference["items"][0]["title"] == "Pro"
assert preference["items"][0]["unit_price"] == 100
def test_create_checkout_error_status_raises(self):
gateway = MercadoPagoGateway({"access_token": "tok"})
with mock.patch("mercadopago.SDK") as sdk_cls:
sdk_cls.return_value.preference().create.return_value = {
"status": 400,
"response": {"message": "bad"},
}
with pytest.raises(ValueError, match="MercadoPago error"):
gateway.create_checkout_session(_FakePlan(), _FakeUser())
def test_handle_webhook(self):
gateway = MercadoPagoGateway({"access_token": "tok"})
result = gateway.handle_webhook({"type": "payment", "data": {"id": "pay_1"}}, {})
assert result.event_type == "payment"
assert result.is_handled is True
assert result.data == {"id": "pay_1"}
def test_cancel_subscription(self):
gateway = MercadoPagoGateway({"access_token": "tok"})
with mock.patch("mercadopago.SDK") as sdk_cls:
sdk_cls.return_value.preapproval().update.return_value = {"status": 200}
assert gateway.cancel_subscription(_FakeSubscription()) is True
sdk_cls.return_value.preapproval().update.assert_called_once_with("sub_123", {"status": "cancelled"})
def test_sync_subscription(self):
gateway = MercadoPagoGateway({"access_token": "tok"})
with mock.patch("mercadopago.SDK") as sdk_cls:
sdk_cls.return_value.preapproval().get.return_value = {
"status": 200,
"response": {"status": "cancelled", "metadata": {"a": 1}},
}
data = gateway.sync_subscription(_FakeSubscription())
assert data["status"] == "cancelled"
assert data["metadata"] == {"a": 1}
def test_health_check(self):
assert MercadoPagoGateway({"access_token": "t"}).health_check() is True
assert MercadoPagoGateway({}).health_check() is False
class TestWompiGateway:
def test_missing_credentials_raise(self):
gateway = WompiGateway({})
with pytest.raises(ValueError, match="public key"):
gateway.create_checkout_session(_FakePlan(), _FakeUser())
def test_create_checkout_session(self):
gateway = WompiGateway({"public_key": "pub_test"})
with mock.patch("infrasynth.billing.gateways.wompi.requests.post") as post:
post.return_value.status_code = 201
post.return_value.json.return_value = {"data": {"id": "plink_1", "url": "https://checkout.wompi.co/link"}}
result = gateway.create_checkout_session(_FakePlan(), _FakeUser())
assert result.session_id == "plink_1"
assert result.checkout_url == "https://checkout.wompi.co/link"
payload = post.call_args.kwargs["json"]
assert payload["amount_in_cents"] == 100
assert payload["currency"] == "usd"
def test_create_checkout_http_error_raises(self):
gateway = WompiGateway({"public_key": "pub_test"})
with mock.patch("infrasynth.billing.gateways.wompi.requests.post") as post:
post.return_value.status_code = 400
post.return_value.text = "invalid"
with pytest.raises(ValueError, match="400"):
gateway.create_checkout_session(_FakePlan(), _FakeUser())
def test_handle_webhook_unverified_without_secret(self):
gateway = WompiGateway({})
result = gateway.handle_webhook({"event": "transaction.updated", "data": {"id": "t_1"}}, {})
assert result.event_type == "transaction.updated"
assert result.is_handled is True
assert result.data == {"id": "t_1"}
def test_handle_webhook_signature_verification(self):
import hashlib
import hmac
import json
payload = {"event": "transaction.updated", "data": {"id": "t_1"}}
gateway = WompiGateway({"webhook_secret": "whsec"})
raw = json.dumps(payload, separators=(",", ":"))
signature = hmac.new(b"whsec", raw.encode(), hashlib.sha256).hexdigest()
result = gateway.handle_webhook(payload, {"x-signature": signature})
assert result.is_handled is True
result = gateway.handle_webhook(payload, {"x-signature": "tampered"})
assert result.is_handled is False
def test_cancel_subscription_voids_transaction(self):
gateway = WompiGateway({"secret_key": "sk_test"})
with mock.patch("infrasynth.billing.gateways.wompi.requests.post") as post:
post.return_value.status_code = 200
assert gateway.cancel_subscription(_FakeSubscription()) is True
post.assert_called_once_with(
"https://sandbox.wompi.co/v1/transactions/sub_123/void",
headers={"Authorization": "Bearer sk_test"},
timeout=30,
)
def test_cancel_without_secret_returns_false(self):
gateway = WompiGateway({})
assert gateway.cancel_subscription(_FakeSubscription()) is False
def test_sync_subscription(self):
gateway = WompiGateway({"public_key": "pub"})
with mock.patch("infrasynth.billing.gateways.wompi.requests.get") as get:
get.return_value.status_code = 200
get.return_value.json.return_value = {"data": {"status": "APPROVED", "metadata": {"m": 1}}}
data = gateway.sync_subscription(_FakeSubscription())
assert data["status"] == "active"
assert data["metadata"] == {"m": 1}
def test_get_invoice(self):
gateway = WompiGateway({"public_key": "pub"})
with mock.patch("infrasynth.billing.gateways.wompi.requests.get") as get:
get.return_value.status_code = 200
get.return_value.json.return_value = {
"data": {
"id": "t_1",
"status": "VOIDED",
"amount_in_cents": 9900,
"currency": "cop",
}
}
data = gateway.get_invoice(_FakeInvoice())
assert data["status"] == "VOIDED"
assert data["amount"] == 99.0
assert data["currency"] == "COP"
def test_health_check(self):
assert WompiGateway({"public_key": "p"}).health_check() is True
assert WompiGateway({}).health_check() is False