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
213 lines
7.2 KiB
Python
213 lines
7.2 KiB
Python
import uuid
|
|
|
|
import pytest
|
|
from django.dispatch import receiver
|
|
from django.test import override_settings
|
|
|
|
from infrasynth.audit.models import SecurityEvent
|
|
from infrasynth.billing.models import App, PaymentGateway, Plan
|
|
from infrasynth.billing.services import BillingService
|
|
from infrasynth.billing.signals import (
|
|
invoice_generated,
|
|
subscription_cancelled,
|
|
subscription_created,
|
|
)
|
|
from infrasynth.notifications.models import ChannelConfig, NotificationDispatch, NotificationTemplate
|
|
from infrasynth.notifications.services import NotificationService
|
|
from infrasynth.shared.enums import ChannelType, MonetizationModel
|
|
from infrasynth.webhooks.signals import outbound_delivery_succeeded
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway():
|
|
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 notification_template():
|
|
return NotificationTemplate.all_objects.create(
|
|
slug="sub-created",
|
|
name="Subscription Created",
|
|
channel=ChannelType.EMAIL,
|
|
subject_template="Your subscription is active",
|
|
body_template="Hello, your {{ plan }} plan is now active.",
|
|
is_html=False,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def channel_config(tenant):
|
|
return ChannelConfig.objects.create(
|
|
tenant=tenant,
|
|
slug="email",
|
|
channel_type=ChannelType.EMAIL,
|
|
display_name="Email Channel",
|
|
config={},
|
|
is_active=True,
|
|
)
|
|
|
|
|
|
class TestSubscriptionCreatedSignalIntegration:
|
|
def test_subscription_created_signal_integration(self, tenant, plan, gateway):
|
|
sent = []
|
|
receiver_fn = lambda **kw: sent.append(kw) # noqa: E731
|
|
subscription_created.connect(receiver_fn, weak=False)
|
|
|
|
try:
|
|
BillingService().create_subscription(plan, tenant, gateway, external_id="sub_int_1")
|
|
|
|
assert len(sent) == 1
|
|
assert sent[0]["tenant_id"] == str(tenant.pk)
|
|
assert sent[0]["plan_slug"] == "pro"
|
|
assert sent[0]["app_slug"] == "messenger"
|
|
assert sent[0]["gateway"] == "fake"
|
|
assert sent[0]["external_id"] == "sub_int_1"
|
|
assert sent[0]["signal"] is subscription_created
|
|
finally:
|
|
subscription_created.disconnect(receiver_fn)
|
|
|
|
|
|
class TestBillingSignalTriggersNotificationDispatch:
|
|
@override_settings(
|
|
INFRASYNTH_NOTIFICATIONS={
|
|
"DISPATCH_BACKEND": "celery",
|
|
"CHANNELS": {
|
|
"email": {
|
|
"primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
def test_billing_signal_triggers_notification_dispatch(
|
|
self, tenant, plan, gateway, notification_template, channel_config
|
|
):
|
|
dispatch_created = []
|
|
|
|
@receiver(subscription_created, weak=False)
|
|
def handle_sub_created(**kwargs):
|
|
svc = NotificationService()
|
|
d = svc.send(
|
|
recipient="owner@example.com",
|
|
subject="Subscription Active",
|
|
body="Your subscription is active.",
|
|
channel=ChannelType.EMAIL,
|
|
template=notification_template,
|
|
context={"plan": kwargs["plan_slug"]},
|
|
)
|
|
dispatch_created.append(d)
|
|
|
|
try:
|
|
BillingService().create_subscription(plan, tenant, gateway, external_id="sub_notif_1")
|
|
|
|
assert len(dispatch_created) == 1
|
|
dispatch = dispatch_created[0]
|
|
assert isinstance(dispatch, NotificationDispatch)
|
|
assert dispatch.recipient == "owner@example.com"
|
|
assert dispatch.channel == ChannelType.EMAIL
|
|
assert dispatch.template == notification_template
|
|
assert dispatch.status == NotificationDispatch.Status.PENDING
|
|
finally:
|
|
subscription_created.disconnect(handle_sub_created)
|
|
|
|
|
|
class TestWebhookSignalTriggersAuditLog:
|
|
def test_webhook_signal_triggers_audit_log(self, tenant, user):
|
|
events_created = []
|
|
|
|
@receiver(outbound_delivery_succeeded, weak=False)
|
|
def create_security_event(**kwargs):
|
|
event = SecurityEvent.objects.create(
|
|
tenant=tenant,
|
|
event_type="webhook_delivery_succeeded",
|
|
actor=user,
|
|
metadata={
|
|
"delivery_id": kwargs["delivery_id"],
|
|
"event_name": kwargs.get("event_name", ""),
|
|
"status_code": kwargs.get("status_code", 0),
|
|
},
|
|
request_id=str(uuid.uuid4()),
|
|
)
|
|
events_created.append(event)
|
|
|
|
try:
|
|
outbound_delivery_succeeded.send(
|
|
sender=None,
|
|
delivery_id=42,
|
|
event_name="subscription.created",
|
|
status_code=200,
|
|
)
|
|
|
|
assert len(events_created) == 1
|
|
event = SecurityEvent.objects.get(pk=events_created[0].pk)
|
|
assert event.event_type == "webhook_delivery_succeeded"
|
|
assert event.actor == user
|
|
assert event.metadata["delivery_id"] == 42
|
|
assert event.metadata["event_name"] == "subscription.created"
|
|
assert event.metadata["status_code"] == 200
|
|
finally:
|
|
outbound_delivery_succeeded.disconnect(create_security_event)
|
|
|
|
|
|
class TestSubscriptionCancelledSignal:
|
|
def test_subscription_cancelled_signal(self, tenant, plan, gateway):
|
|
subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_to_cancel")
|
|
|
|
sent = []
|
|
receiver_fn = lambda **kw: sent.append(kw) # noqa: E731
|
|
subscription_cancelled.connect(receiver_fn, weak=False)
|
|
|
|
try:
|
|
BillingService().cancel_subscription(subscription)
|
|
|
|
assert len(sent) == 1
|
|
assert sent[0]["tenant_id"] == str(tenant.pk)
|
|
assert sent[0]["plan_slug"] == "pro"
|
|
assert sent[0]["reason"] == "user_requested"
|
|
assert sent[0]["signal"] is subscription_cancelled
|
|
finally:
|
|
subscription_cancelled.disconnect(receiver_fn)
|
|
|
|
|
|
class TestInvoiceGeneratedSignal:
|
|
def test_invoice_generated_signal(self, tenant, plan, gateway):
|
|
subscription = BillingService().create_subscription(plan, tenant, gateway, external_id="sub_inv_1")
|
|
|
|
sent = []
|
|
receiver_fn = lambda **kw: sent.append(kw) # noqa: E731
|
|
invoice_generated.connect(receiver_fn, weak=False)
|
|
|
|
try:
|
|
invoice = BillingService().generate_invoice(subscription)
|
|
|
|
assert len(sent) == 1
|
|
assert sent[0]["invoice_id"] == invoice.id
|
|
assert sent[0]["amount"] == 4900
|
|
assert sent[0]["signal"] is invoice_generated
|
|
finally:
|
|
invoice_generated.disconnect(receiver_fn)
|