infrasynth-backend-kit/tests/test_billing/test_entitlements.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

108 lines
4.4 KiB
Python

"""EntitlementService enforcement (ENTITLEMENTS.md §4, §5)."""
import pytest
from infrasynth.billing.entitlements import EntitlementService
from infrasynth.billing.models import App, Entitlement, Plan
from infrasynth.billing.services import BillingService
from infrasynth.tenancy.models import Tenant
pytestmark = pytest.mark.django_db
@pytest.fixture
def app():
return App.objects.create(slug="messenger", name="Messenger", monetization="subscription")
@pytest.fixture
def plan(app):
return Plan.objects.create(
app=app,
slug="pro",
name="Pro",
price_amount=1000,
features={"broadcast": True, "analytics": False},
limits={"max_agents": 10},
)
def _entitlement(tenant, app, plan=None, status="active"):
return Entitlement.all_objects.create(tenant=tenant, app=app, plan=plan, status=status)
class TestIsEntitled:
def test_false_without_entitlement(self, tenant, app):
assert EntitlementService().is_entitled(tenant, "messenger") is False
def test_active_is_entitled(self, tenant, app, plan):
_entitlement(tenant, app, plan)
assert EntitlementService().is_entitled(tenant, "messenger") is True
@pytest.mark.parametrize("status", ["trialing", "grace", "past_due"])
def test_grace_and_trial_are_entitled(self, tenant, app, plan, status):
_entitlement(tenant, app, plan, status=status)
assert EntitlementService().is_entitled(tenant, "messenger") is True
@pytest.mark.parametrize("status", ["suspended", "expired", "cancelled", "revoked"])
def test_inactive_statuses_are_not_entitled(self, tenant, app, plan, status):
_entitlement(tenant, app, plan, status=status)
assert EntitlementService().is_entitled(tenant, "messenger") is False
def test_suspended_tenant_denied_regardless_of_entitlement(self, tenant, app, plan):
_entitlement(tenant, app, plan, status="active")
tenant.status = Tenant.Status.SUSPENDED
tenant.save(update_fields=["status"])
assert EntitlementService().is_entitled(tenant, "messenger") is False
def test_feature_gate_reads_plan_features(self, tenant, app, plan):
_entitlement(tenant, app, plan)
svc = EntitlementService()
assert svc.is_entitled(tenant, "messenger", feature="broadcast") is True
assert svc.is_entitled(tenant, "messenger", feature="analytics") is False
assert svc.is_entitled(tenant, "messenger", feature="unknown") is False
def test_none_tenant_is_never_entitled(self, app):
assert EntitlementService().is_entitled(None, "messenger") is False
class TestCheckLimit:
def test_within_and_at_limit(self, tenant, app, plan):
_entitlement(tenant, app, plan)
svc = EntitlementService()
assert svc.check_limit(tenant, "messenger", "max_agents", 9) is True
assert svc.check_limit(tenant, "messenger", "max_agents", 10) is False
def test_missing_limit_is_unlimited(self, tenant, app, plan):
_entitlement(tenant, app, plan)
assert EntitlementService().check_limit(tenant, "messenger", "nonexistent", 10**9) is True
def test_no_entitlement_denies(self, tenant, app):
assert EntitlementService().check_limit(tenant, "messenger", "max_agents", 0) is False
class TestCacheInvalidation:
def test_mutation_invalidates_cache(self, tenant, app, plan):
entitlement = _entitlement(tenant, app, plan)
svc = EntitlementService()
assert svc.is_entitled(tenant, "messenger") is True
entitlement.status = "revoked"
entitlement.save(update_fields=["status"])
# still cached
assert svc.is_entitled(tenant, "messenger") is True
svc.invalidate(tenant, "messenger")
assert svc.is_entitled(tenant, "messenger") is False
class TestLifecycleTransitions:
def test_past_due_grace_suspend_reinstate(self, tenant, app, plan):
entitlement = _entitlement(tenant, app, plan)
svc = BillingService()
svc.mark_past_due(entitlement)
assert EntitlementService().get(tenant, "messenger").status == "past_due"
svc.enter_grace(entitlement)
assert EntitlementService().is_entitled(tenant, "messenger") is True
svc.suspend_entitlement(entitlement)
assert EntitlementService().is_entitled(tenant, "messenger") is False
svc.reinstate_entitlement(entitlement)
assert EntitlementService().is_entitled(tenant, "messenger") is True