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
254 lines
9 KiB
Python
254 lines
9 KiB
Python
import pytest
|
|
|
|
from infrasynth.billing.models import App, Entitlement, Invoice, PaymentGateway, PaymentTransaction, Plan, Subscription
|
|
from infrasynth.features.models import FeatureFlag
|
|
from infrasynth.shared.enums import MonetizationModel, SubscriptionStatus
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
GATEWAYS_URL = "/api/v1/billing/gateways/"
|
|
APPS_URL = "/api/v1/billing/apps/"
|
|
PLANS_URL = "/api/v1/billing/plans/"
|
|
ENTITLEMENTS_URL = "/api/v1/billing/entitlements/"
|
|
SUBSCRIPTIONS_URL = "/api/v1/billing/subscriptions/"
|
|
INVOICES_URL = "/api/v1/billing/invoices/"
|
|
TRANSACTIONS_URL = "/api/v1/billing/transactions/"
|
|
CHECKOUT_URL = "/api/v1/billing/checkout/"
|
|
WEBHOOK_URL = "/api/v1/billing/webhook/receive/"
|
|
|
|
|
|
@pytest.fixture
|
|
def billing_enabled():
|
|
return FeatureFlag.objects.create(slug="billing", name="Billing", is_active=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway(billing_enabled):
|
|
return PaymentGateway.objects.create(
|
|
slug="fake",
|
|
display_name="Fake",
|
|
gateway_class="tests.helpers.FakeGateway",
|
|
is_active=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return App.objects.create(slug="messenger", name="Messenger", monetization=MonetizationModel.SUBSCRIPTION)
|
|
|
|
|
|
@pytest.fixture
|
|
def plan(app, gateway):
|
|
return Plan.objects.create(
|
|
app=app,
|
|
slug="pro",
|
|
name="Pro",
|
|
price_amount=4900,
|
|
price_currency="USD",
|
|
interval="monthly",
|
|
gateway=gateway,
|
|
external_id="price_123",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def subscription(tenant, plan, gateway):
|
|
return Subscription.all_objects.create(
|
|
tenant=tenant,
|
|
plan=plan,
|
|
gateway=gateway,
|
|
external_id="sub_1",
|
|
status=SubscriptionStatus.ACTIVE,
|
|
)
|
|
|
|
|
|
class TestFeatureGate:
|
|
def test_disabled_feature_returns_404(self, authenticated_client):
|
|
assert authenticated_client.get(GATEWAYS_URL).status_code == 404
|
|
assert authenticated_client.get(SUBSCRIPTIONS_URL).status_code == 404
|
|
assert authenticated_client.get(INVOICES_URL).status_code == 404
|
|
|
|
def test_catalog_is_public(self, api_client, plan):
|
|
assert api_client.get(APPS_URL).status_code == 200
|
|
assert api_client.get(PLANS_URL).status_code == 200
|
|
|
|
|
|
class TestPaymentGatewayViewSet:
|
|
def test_list_gateways(self, authenticated_client, gateway):
|
|
response = authenticated_client.get(GATEWAYS_URL)
|
|
assert response.status_code == 200
|
|
assert response.data["count"] == 1
|
|
assert response.data["results"][0]["slug"] == "fake"
|
|
|
|
def test_create_gateway(self, authenticated_client, billing_enabled):
|
|
response = authenticated_client.post(
|
|
GATEWAYS_URL,
|
|
{
|
|
"slug": "stripe",
|
|
"display_name": "Stripe",
|
|
"gateway_class": "infrasynth.billing.gateways.stripe.StripeGateway",
|
|
},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 201
|
|
assert PaymentGateway.objects.count() == 1
|
|
|
|
def test_requires_auth(self, api_client, gateway):
|
|
assert api_client.get(GATEWAYS_URL).status_code == 401
|
|
|
|
|
|
class TestPlanViewSet:
|
|
def test_list_plans_public(self, api_client, plan):
|
|
response = api_client.get(PLANS_URL)
|
|
assert response.status_code == 200
|
|
assert response.data["count"] == 1
|
|
assert response.data["results"][0]["slug"] == "pro"
|
|
|
|
def test_retrieve_plan(self, api_client, plan):
|
|
response = api_client.get(f"{PLANS_URL}{plan.slug}/")
|
|
assert response.status_code == 200
|
|
assert response.data["price_amount"] == 4900
|
|
assert response.data["gateway_info"]["slug"] == "fake"
|
|
assert response.data["app_info"]["slug"] == "messenger"
|
|
|
|
def test_retrieve_inactive_plan_404(self, api_client, plan):
|
|
plan.is_active = False
|
|
plan.save(update_fields=["is_active"])
|
|
assert api_client.get(f"{PLANS_URL}{plan.slug}/").status_code == 404
|
|
|
|
|
|
class TestEntitlementViewSet:
|
|
def test_list_entitlements(self, authenticated_client, tenant, plan):
|
|
Entitlement.all_objects.create(tenant=tenant, app=plan.app, plan=plan)
|
|
response = authenticated_client.get(ENTITLEMENTS_URL)
|
|
assert response.status_code == 200
|
|
assert response.data["count"] == 1
|
|
assert response.data["results"][0]["app_info"]["slug"] == "messenger"
|
|
|
|
def test_requires_auth(self, api_client, tenant, plan):
|
|
Entitlement.all_objects.create(tenant=tenant, app=plan.app, plan=plan)
|
|
assert api_client.get(ENTITLEMENTS_URL).status_code == 401
|
|
|
|
|
|
class TestSubscriptionViewSet:
|
|
def test_list_subscriptions(self, authenticated_client, subscription):
|
|
response = authenticated_client.get(SUBSCRIPTIONS_URL)
|
|
assert response.status_code == 200
|
|
assert response.data["count"] == 1
|
|
assert response.data["results"][0]["status"] == "active"
|
|
|
|
def test_requires_auth(self, api_client, subscription):
|
|
assert api_client.get(SUBSCRIPTIONS_URL).status_code == 401
|
|
|
|
def test_subscribe_action(self, authenticated_client, plan):
|
|
response = authenticated_client.post(
|
|
f"{SUBSCRIPTIONS_URL}subscribe/",
|
|
{"plan_slug": "pro"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["checkout_url"] == "https://checkout.example.com/cs_fake_123"
|
|
assert response.data["session_id"] == "cs_fake_123"
|
|
assert response.data["gateway"] == "fake"
|
|
|
|
def test_subscribe_invalid_plan(self, authenticated_client):
|
|
response = authenticated_client.post(
|
|
f"{SUBSCRIPTIONS_URL}subscribe/",
|
|
{"plan_slug": "nope"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
def test_checkout_action(self, authenticated_client, plan):
|
|
response = authenticated_client.post(
|
|
CHECKOUT_URL,
|
|
{"app": "messenger", "plan": "pro"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["checkout_url"] == "https://checkout.example.com/cs_fake_123"
|
|
assert response.data["app_slug"] == "messenger"
|
|
|
|
|
|
class TestInvoiceViewSet:
|
|
def test_list_invoices(self, authenticated_client, tenant, subscription):
|
|
Invoice.all_objects.create(
|
|
tenant=tenant,
|
|
subscription=subscription,
|
|
invoice_number="INV-1",
|
|
amount=4900,
|
|
currency="USD",
|
|
status="open",
|
|
)
|
|
response = authenticated_client.get(INVOICES_URL)
|
|
assert response.status_code == 200
|
|
assert response.data["count"] == 1
|
|
assert response.data["results"][0]["invoice_number"] == "INV-1"
|
|
|
|
def test_requires_auth(self, api_client, subscription):
|
|
assert api_client.get(INVOICES_URL).status_code == 401
|
|
|
|
|
|
class TestPaymentTransactionViewSet:
|
|
def test_list_transactions(self, authenticated_client, tenant, subscription):
|
|
PaymentTransaction.all_objects.create(
|
|
tenant=tenant,
|
|
invoice=None,
|
|
gateway=subscription.gateway,
|
|
amount=4900,
|
|
currency="USD",
|
|
status="approved",
|
|
)
|
|
response = authenticated_client.get(TRANSACTIONS_URL)
|
|
assert response.status_code == 200
|
|
assert response.data["count"] == 1
|
|
assert response.data["results"][0]["status"] == "approved"
|
|
|
|
def test_requires_auth(self, api_client, billing_enabled):
|
|
assert api_client.get(TRANSACTIONS_URL).status_code == 401
|
|
|
|
|
|
class TestWebhookReceiveView:
|
|
def test_receive_processed(self, api_client, gateway):
|
|
response = api_client.post(
|
|
WEBHOOK_URL,
|
|
{
|
|
"gateway_slug": "fake",
|
|
"event": "checkout.session.completed",
|
|
"data": {"id": "cs_1"},
|
|
},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["status"] == "processed"
|
|
assert response.data["event_type"] == "checkout.session.completed"
|
|
|
|
def test_receive_ignored_event(self, api_client, gateway):
|
|
response = api_client.post(
|
|
WEBHOOK_URL,
|
|
{"gateway_slug": "fake", "event": "ignored", "data": {}},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["status"] == "ignored"
|
|
|
|
def test_receive_uses_first_active_gateway(self, api_client, gateway):
|
|
response = api_client.post(
|
|
WEBHOOK_URL,
|
|
{"event": "checkout.session.completed", "data": {}},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.data["event_type"] == "checkout.session.completed"
|
|
|
|
def test_receive_no_gateway_404(self, api_client):
|
|
response = api_client.post(WEBHOOK_URL, {"event": "x", "data": {}}, format="json")
|
|
assert response.status_code == 404
|
|
|
|
def test_receive_is_public(self, api_client, gateway):
|
|
response = api_client.post(
|
|
WEBHOOK_URL,
|
|
{"gateway_slug": "fake", "event": "e", "data": {}},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 200
|