"""Tests for B-2.8 — Stripe Webhook (subscription lifecycle + idempotency). Covers: - Signature verification (rejects missing/invalid; honors STRIPE_WEBHOOK_SECRET) - Idempotency via WebhookEvent table - Each event handler: checkout.session.completed customer.subscription.updated customer.subscription.deleted invoice.payment_succeeded invoice.payment_failed - User resolution order (stripe_customer_id → metadata → email) - 500 retry path on handler exceptions Mocks `_verify_event` (since real signatures need a real secret) and `stripe.Subscription.retrieve` to avoid real Stripe API calls. Note: pytest cannot collect this file on Windows native because src/init_db.py imports `fcntl` (POSIX-only). Use tests/_run_stripe_webhook_windows.py. """ import json import os import sys from unittest.mock import patch, MagicMock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:') os.environ.setdefault('SECRET_KEY', 'test-secret-key-webhook') os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false') os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false') os.environ.setdefault('RATELIMIT_ENABLED', 'false') from src.app import app, db, bcrypt # noqa: E402 from src.models.user import User # noqa: E402 from src.models.subscription import Subscription # noqa: E402 from src.models.webhook_event import WebhookEvent # noqa: E402 _WEBHOOK_ENV_VARS = ( 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', ) def _clear_webhook_env(): for k in _WEBHOOK_ENV_VARS: os.environ.pop(k, None) import stripe stripe.api_key = None def _disable_csrf(): app.config['WTF_CSRF_ENABLED'] = False def _make_user(email='hookuser@example.qc.ca', password='Password!123', username=None, name='Hook User', stripe_customer_id=None, subscription_status=None): hashed = bcrypt.generate_password_hash(password).decode('utf-8') u = User( username=username or email.split('@', 1)[0][:20], email=email, password=hashed, email_verified=True, name=name, stripe_customer_id=stripe_customer_id, subscription_status=subscription_status, ) db.session.add(u) db.session.commit() return u def _make_event(event_type, obj_data, event_id='evt_test_123'): """Build a fake Stripe event for testing webhook handlers.""" return MagicMock( id=event_id, type=event_type, data=MagicMock(object=obj_data), ) def _make_checkout_session(customer='cus_test', subscription='sub_test', plan_slug='dictia-cloud', period='monthly', email=None, user_id='1'): return { 'id': 'cs_test_abc', 'customer': customer, 'subscription': subscription, 'customer_email': email, 'metadata': { 'dictia_user_id': user_id, 'dictia_plan_slug': plan_slug, 'dictia_period': period, }, } def _make_subscription_obj(sub_id='sub_test', customer='cus_test', status='active', period_end=1730000000, plan_slug='dictia-cloud', period='monthly'): return { 'id': sub_id, 'customer': customer, 'status': status, 'current_period_end': period_end, 'metadata': {'dictia_plan_slug': plan_slug, 'dictia_period': period}, } def _make_invoice(sub_id='sub_test', customer='cus_test'): return { 'id': 'in_test_123', 'customer': customer, 'subscription': sub_id, } def _post_webhook(client, payload=b'{}', signature='t=1,v1=fake'): return client.post( '/checkout/webhooks/stripe', data=payload, headers={'Stripe-Signature': signature, 'Content-Type': 'application/json'}, ) # ---------------------------------------------------------------------- # 1-3. Signature verification # ---------------------------------------------------------------------- def test_webhook_rejects_missing_signature(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: with app.test_client() as client: # No Stripe-Signature header resp = client.post( '/checkout/webhooks/stripe', data=b'{}', headers={'Content-Type': 'application/json'}, ) assert resp.status_code == 400 body = resp.get_json() assert body.get('error') == 'invalid_signature' finally: db.session.rollback() db.drop_all() _clear_webhook_env() def test_webhook_rejects_invalid_signature(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: with app.test_client() as client: # Real construct_event will fail on bogus signature resp = _post_webhook(client, payload=b'{"id":"evt_x"}', signature='t=1,v1=bogus') assert resp.status_code == 400 assert resp.get_json().get('error') == 'invalid_signature' finally: db.session.rollback() db.drop_all() _clear_webhook_env() def test_webhook_rejects_when_secret_not_configured(): with app.app_context(): _disable_csrf() _clear_webhook_env() # NO STRIPE_WEBHOOK_SECRET db.create_all() try: with app.test_client() as client: resp = _post_webhook(client, payload=b'{"id":"evt_x"}') assert resp.status_code == 400 assert resp.get_json().get('error') == 'invalid_signature' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 4. Idempotency # ---------------------------------------------------------------------- def test_webhook_idempotent_on_duplicate_event_id(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='dup@example.qc.ca', stripe_customer_id='cus_dup') event = _make_event( 'checkout.session.completed', _make_checkout_session(customer='cus_dup', subscription='sub_dup', user_id=str(user.id)), event_id='evt_dup_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_verify.return_value = event mock_retr.return_value = { 'current_period_end': 1730000000, } with app.test_client() as client: # First call processes resp1 = _post_webhook(client) assert resp1.status_code == 200 assert resp1.get_json().get('received') is True # Second call dedup'd resp2 = _post_webhook(client) assert resp2.status_code == 200 body2 = resp2.get_json() assert body2.get('duplicate') is True # Only one Subscription row + one WebhookEvent row assert Subscription.query.filter_by(stripe_subscription_id='sub_dup').count() == 1 assert WebhookEvent.query.filter_by(stripe_event_id='evt_dup_1').count() == 1 finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 5. Unhandled event types # ---------------------------------------------------------------------- def test_webhook_acks_unhandled_event_type(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: event = _make_event('customer.created', {'id': 'cus_new'}, event_id='evt_unhandled_1') with patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 body = resp.get_json() assert body.get('received') is True assert body.get('handled') is False # Recorded so Stripe stops retrying assert WebhookEvent.query.filter_by(stripe_event_id='evt_unhandled_1').count() == 1 finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 6. checkout.session.completed — happy path # ---------------------------------------------------------------------- def test_checkout_session_completed_creates_subscription_and_sets_user_status(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='create@example.qc.ca', stripe_customer_id='cus_test') event = _make_event( 'checkout.session.completed', _make_checkout_session(customer='cus_test', subscription='sub_test', user_id=str(user.id)), event_id='evt_create_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_verify.return_value = event mock_retr.return_value = {'current_period_end': 1730000000} with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 sub = Subscription.query.filter_by(stripe_subscription_id='sub_test').first() assert sub is not None assert sub.user_id == user.id assert sub.plan_slug == 'dictia-cloud' assert sub.period == 'monthly' assert sub.status == 'active' assert sub.current_period_end is not None db.session.refresh(user) assert user.subscription_status == 'active' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 7. User resolution by stripe_customer_id # ---------------------------------------------------------------------- def test_checkout_session_completed_resolves_user_by_stripe_customer_id(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='resolve@example.qc.ca', stripe_customer_id='cus_resolve') session_obj = _make_checkout_session( customer='cus_resolve', subscription='sub_resolve', user_id='', ) # No metadata.dictia_user_id session_obj['metadata'].pop('dictia_user_id', None) event = _make_event('checkout.session.completed', session_obj, event_id='evt_resolve_1') with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_verify.return_value = event mock_retr.return_value = {'current_period_end': 1730000000} with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 sub = Subscription.query.filter_by(stripe_subscription_id='sub_resolve').first() assert sub is not None assert sub.user_id == user.id finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 8. User resolution by metadata when customer unknown # ---------------------------------------------------------------------- def test_checkout_session_completed_resolves_user_by_metadata_when_customer_unknown(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='meta@example.qc.ca', stripe_customer_id=None) event = _make_event( 'checkout.session.completed', _make_checkout_session(customer='cus_new', subscription='sub_meta', user_id=str(user.id)), event_id='evt_meta_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_verify.return_value = event mock_retr.return_value = {'current_period_end': 1730000000} with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 sub = Subscription.query.filter_by(stripe_subscription_id='sub_meta').first() assert sub is not None assert sub.user_id == user.id db.session.refresh(user) # stripe_customer_id is bound from the event assert user.stripe_customer_id == 'cus_new' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 9. Idempotency via subscription_id uniqueness (different event IDs, same sub) # ---------------------------------------------------------------------- def test_checkout_session_completed_idempotent_via_subscription_id_uniqueness(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='subdup@example.qc.ca', stripe_customer_id='cus_subdup') session_obj = _make_checkout_session( customer='cus_subdup', subscription='sub_subdup', user_id=str(user.id), ) event_a = _make_event('checkout.session.completed', session_obj, event_id='evt_subdup_a') event_b = _make_event('checkout.session.completed', session_obj, event_id='evt_subdup_b') with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_retr.return_value = {'current_period_end': 1730000000} with app.test_client() as client: mock_verify.return_value = event_a resp1 = _post_webhook(client) assert resp1.status_code == 200 mock_verify.return_value = event_b resp2 = _post_webhook(client) assert resp2.status_code == 200 # Only one Subscription row despite two distinct events assert Subscription.query.filter_by(stripe_subscription_id='sub_subdup').count() == 1 # But both events recorded assert WebhookEvent.query.filter_by(stripe_event_id='evt_subdup_a').count() == 1 assert WebhookEvent.query.filter_by(stripe_event_id='evt_subdup_b').count() == 1 finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 10. customer.subscription.updated # ---------------------------------------------------------------------- def test_subscription_updated_updates_status_and_period_end(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: from datetime import datetime user = _make_user(email='upd@example.qc.ca', stripe_customer_id='cus_upd', subscription_status='active') existing = Subscription( user_id=user.id, stripe_customer_id='cus_upd', stripe_subscription_id='sub_upd', plan_slug='dictia-cloud', period='monthly', status='active', current_period_end=datetime(2025, 1, 1), ) db.session.add(existing) db.session.commit() event = _make_event( 'customer.subscription.updated', _make_subscription_obj(sub_id='sub_upd', customer='cus_upd', status='past_due', period_end=1735689600), # 2025-01-01 event_id='evt_upd_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 db.session.refresh(existing) assert existing.status == 'past_due' assert existing.current_period_end is not None db.session.refresh(user) assert user.subscription_status == 'past_due' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 11. customer.subscription.updated when row missing # ---------------------------------------------------------------------- def test_subscription_updated_creates_row_if_missing(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='race@example.qc.ca', stripe_customer_id='cus_race') event = _make_event( 'customer.subscription.updated', _make_subscription_obj(sub_id='sub_race', customer='cus_race', status='active'), event_id='evt_race_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 sub = Subscription.query.filter_by(stripe_subscription_id='sub_race').first() assert sub is not None assert sub.user_id == user.id assert sub.status == 'active' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 12. customer.subscription.deleted # ---------------------------------------------------------------------- def test_subscription_deleted_marks_canceled(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='del@example.qc.ca', stripe_customer_id='cus_del', subscription_status='active') existing = Subscription( user_id=user.id, stripe_customer_id='cus_del', stripe_subscription_id='sub_del', plan_slug='dictia-cloud', period='monthly', status='active', ) db.session.add(existing) db.session.commit() event = _make_event( 'customer.subscription.deleted', _make_subscription_obj(sub_id='sub_del', customer='cus_del', status='canceled'), event_id='evt_del_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 db.session.refresh(existing) assert existing.status == 'canceled' db.session.refresh(user) assert user.subscription_status == 'canceled' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 13. invoice.payment_succeeded — recovers past_due # ---------------------------------------------------------------------- def test_invoice_payment_succeeded_recovers_past_due(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='paysucc@example.qc.ca', stripe_customer_id='cus_paysucc', subscription_status='past_due') existing = Subscription( user_id=user.id, stripe_customer_id='cus_paysucc', stripe_subscription_id='sub_paysucc', plan_slug='dictia-cloud', period='monthly', status='past_due', ) db.session.add(existing) db.session.commit() event = _make_event( 'invoice.payment_succeeded', _make_invoice(sub_id='sub_paysucc', customer='cus_paysucc'), event_id='evt_paysucc_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 db.session.refresh(existing) assert existing.status == 'active' db.session.refresh(user) assert user.subscription_status == 'active' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 14. invoice.payment_failed # ---------------------------------------------------------------------- def test_invoice_payment_failed_marks_past_due(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='payfail@example.qc.ca', stripe_customer_id='cus_payfail', subscription_status='active') existing = Subscription( user_id=user.id, stripe_customer_id='cus_payfail', stripe_subscription_id='sub_payfail', plan_slug='dictia-cloud', period='monthly', status='active', ) db.session.add(existing) db.session.commit() event = _make_event( 'invoice.payment_failed', _make_invoice(sub_id='sub_payfail', customer='cus_payfail'), event_id='evt_payfail_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 db.session.refresh(existing) assert existing.status == 'past_due' db.session.refresh(user) assert user.subscription_status == 'past_due' finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 15. Handler exception → 500 (Stripe retry) # ---------------------------------------------------------------------- def test_handler_exception_returns_500_for_stripe_retry(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='boom@example.qc.ca', stripe_customer_id='cus_boom') event = _make_event( 'checkout.session.completed', _make_checkout_session(customer='cus_boom', subscription='sub_boom', user_id=str(user.id)), event_id='evt_boom_1', ) # Patch the dispatch table entry so our raising stub fires inside the # try/except handler block in stripe_webhook(). Patching the module # attribute alone wouldn't help because _HANDLERS captures the original # reference at module load. from src.billing import webhooks as wh_mod def _boom(_event): raise RuntimeError('boom') with patch.dict(wh_mod._HANDLERS, {'checkout.session.completed': _boom}), \ patch('src.billing.webhooks._verify_event') as mock_verify: mock_verify.return_value = event with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 500 assert resp.get_json().get('error') == 'handler_failed' # No WebhookEvent recorded (rolled back) assert WebhookEvent.query.filter_by(stripe_event_id='evt_boom_1').count() == 0 assert Subscription.query.filter_by(stripe_subscription_id='sub_boom').count() == 0 finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 16. Metadata 'dictia_user_id' invalid → falls through # ---------------------------------------------------------------------- def test_resolve_user_metadata_dictia_user_id_invalid_falls_through(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='emailfb@example.qc.ca', stripe_customer_id=None) session_obj = _make_checkout_session( customer='cus_unknown', subscription='sub_emailfb', user_id='not-a-number', email='emailfb@example.qc.ca', ) event = _make_event('checkout.session.completed', session_obj, event_id='evt_emailfb_1') with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_verify.return_value = event mock_retr.return_value = {'current_period_end': 1730000000} with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 sub = Subscription.query.filter_by(stripe_subscription_id='sub_emailfb').first() assert sub is not None # Resolved by email assert sub.user_id == user.id finally: db.session.rollback() db.drop_all() _clear_webhook_env() # ---------------------------------------------------------------------- # 17. WebhookEvent records subscription_id for audit # ---------------------------------------------------------------------- def test_webhook_event_records_subscription_id_for_audit(): with app.app_context(): _disable_csrf() _clear_webhook_env() os.environ['STRIPE_SECRET_KEY'] = 'sk_test_fake' os.environ['STRIPE_WEBHOOK_SECRET'] = 'whsec_fake' db.create_all() try: user = _make_user(email='audit@example.qc.ca', stripe_customer_id='cus_audit') event = _make_event( 'checkout.session.completed', _make_checkout_session(customer='cus_audit', subscription='sub_audit', user_id=str(user.id)), event_id='evt_audit_1', ) with patch('src.billing.webhooks._verify_event') as mock_verify, \ patch('src.billing.webhooks.stripe.Subscription.retrieve') as mock_retr: mock_verify.return_value = event mock_retr.return_value = {'current_period_end': 1730000000} with app.test_client() as client: resp = _post_webhook(client) assert resp.status_code == 200 wh = WebhookEvent.query.filter_by(stripe_event_id='evt_audit_1').first() assert wh is not None assert wh.event_type == 'checkout.session.completed' assert wh.stripe_subscription_id == 'sub_audit' assert wh.stripe_customer_id == 'cus_audit' finally: db.session.rollback() db.drop_all() _clear_webhook_env()