//! Route-layer contract tests for `routes::stripe::webhook`, the v1 dispatcher: //! the envelope handling every Stripe event passes through before any handler //! sees it. //! //! Its siblings cover what the handlers do. `stripe_webhook_exactly_once` owns //! the checkout half of the exactly-once protocol, `stripe_webhook_billing_replay` //! the invoice half, and `stripe_webhooks` drives one delivery of each event type //! through to its effect. What none of them assert is the envelope's own //! behaviour, which is where an event is lost rather than mishandled. //! //! Four things only this file pins. //! //! The event lock. A second delivery arriving while the first is mid-flight is //! answered 503 rather than parked on a pooled connection, and the sibling //! suites treat that 503 as timing noise to retry through. Here it is the //! subject: the lock is taken by the test, so the contended path is reached on //! purpose instead of by luck, and the delivery that loses must write nothing at //! all. //! //! The failure path. A handler error ACKs 200 (the local retry queue owns //! redelivery from that point) while leaving the event *unmarked*, so both the //! queue worker and a Stripe redelivery still re-run it. `stripe_webhooks` checks //! the queue row; the unmarked half is the one that decides whether a retry can //! ever happen, and nothing checked it. //! //! The unhandled arm. `MnwEvent::Unhandled` must be acknowledged and marked, not //! queued: Stripe sends event types MNW never asked for, and treating one as a //! failure would fill the retry queue with work that can never succeed. //! //! The settlement gate's other side. `dispatch_checkout_session` defers a //! funds-capturing checkout until it settles; the subscription-mode kinds must //! NOT be deferred, since they capture nothing at checkout and the subscription //! bills separately. `stripe_webhook_exactly_once` pins the deferral. A gate //! that deferred everything would pass that test and silently stop every Fan+ //! and creator-tier signup whose provider reported `unpaid`. use crate::harness::TestHarness; use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload}; use makenotwork::db::UserId; /// Sign an event envelope and POST it, without the 503 retry loop the sibling /// suites use: this file is asserting on that status, so it must not swallow it. async fn post_once( h: &mut TestHarness, event_id: &str, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { let payload = serde_json::json!({ "id": event_id, "type": event_type, "data": {"object": object}, }) .to_string(); let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET); h.client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[ ("stripe-signature", signature.as_str()), ("content-type", "application/json"), ], ) .await } /// Retry through the transient 503 the way Stripe does, for the deliveries a /// test wants to succeed rather than to observe. async fn post_until_settled( h: &mut TestHarness, event_id: &str, event_type: &str, object: serde_json::Value, ) -> crate::harness::client::TestResponse { for _ in 0..200 { let resp = post_once(h, event_id, event_type, object.clone()).await; if resp.status != 503 { return resp; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } panic!("{event_id}: the webhook route answered 503 for two seconds"); } async fn processed(h: &TestHarness, event_id: &str) -> bool { sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM processed_webhook_events WHERE event_id = $1", ) .bind(event_id) .fetch_one(&h.db) .await .expect("count processed markers") > 0 } async fn queued(h: &TestHarness) -> i64 { sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe'") .fetch_one(&h.db) .await .expect("count queued retries") } /// A checkout session with no `checkout_type`, which routes to the purchase /// handler, and no `buyer_id`, which that handler requires. The cheapest way to /// make a handler fail for reasons the envelope has to cope with. fn unroutable_purchase(session_id: &str, seller_id: UserId) -> serde_json::Value { serde_json::json!({ "id": session_id, "object": "checkout_session", "mode": "payment", "payment_status": "paid", "metadata": {"seller_id": seller_id.to_string()}, "payment_intent": format!("pi_{session_id}"), }) } /// The contended path, reached deliberately. The test holds the same advisory /// lock the handler takes, so the delivery below is guaranteed to lose it. /// /// Two halves. The loser must be told to redeliver rather than made to wait on a /// pooled connection, and it must leave nothing behind: a 503 that had already /// marked the event would turn a transient collision into a permanently skipped /// event. Then, with the lock released, the same delivery must go through, which /// is what makes the 503 a deferral rather than a refusal. #[tokio::test] async fn a_delivery_that_loses_the_event_lock_is_deferred_and_writes_nothing() { let mut h = TestHarness::with_stripe().await; let event_id = "evt_dispatch_contended"; // The lock transaction borrows the pool it came from, so it takes its own // handle rather than `h.db`: the harness client needs `&mut h` while the // lock is still held, which is the whole point of the test. let pool = h.db.clone(); let held = makenotwork::db::webhook_events::try_lock_event(&pool, event_id) .await .expect("take the event lock") .expect("the lock is free before the test takes it"); let resp = post_once( &mut h, event_id, "payment_intent.created", serde_json::json!({"id": "pi_dispatch_contended"}), ) .await; assert_eq!( resp.status.as_u16(), 503, "a delivery that loses the lock is told to come back: {}", resp.text ); assert!( !processed(&h, event_id).await, "a deferred delivery must not mark the event, or the redelivery it just \ asked for would be skipped" ); assert_eq!( queued(&h).await, 0, "a deferred delivery is not a failed one and does not enter the retry queue" ); // Releasing the lock is the whole difference; nothing else about the // delivery changes. held.rollback().await.expect("release the event lock"); let resp = post_until_settled( &mut h, event_id, "payment_intent.created", serde_json::json!({"id": "pi_dispatch_contended"}), ) .await; assert_eq!( resp.status.as_u16(), 200, "the same delivery goes through once the lock is free: {}", resp.text ); assert!( processed(&h, event_id).await, "and is marked, so the next redelivery short-circuits" ); } /// A handler error is ACKed so Stripe stops its own redelivery schedule, and the /// event is queued locally instead. The event must NOT be marked: the whole /// point of the queue is that the work still has to happen, and a marked event /// short-circuits both the queue worker's re-run and any Stripe redelivery. #[tokio::test] async fn a_failed_handler_is_queued_and_deliberately_left_unmarked() { let mut h = TestHarness::with_stripe().await; let seller_id = h .signup("dispatchseller", "dispatchseller@test.com", "password123") .await; let event_id = "evt_dispatch_failed"; let resp = post_until_settled( &mut h, event_id, "checkout.session.completed", unroutable_purchase("cs_dispatch_failed", seller_id), ) .await; assert_eq!( resp.status.as_u16(), 200, "the local queue owns retry from here, so Stripe is ACKed: {}", resp.text ); assert_eq!( queued(&h).await, 1, "the event is recoverable from the queue" ); assert!( !processed(&h, event_id).await, "marking a failed event would make both retry routes a no-op" ); // The unmarked half, demonstrated rather than asserted about: a redelivery // of the same event id re-enters the handler instead of short-circuiting. let resp = post_until_settled( &mut h, event_id, "checkout.session.completed", unroutable_purchase("cs_dispatch_failed", seller_id), ) .await; assert_eq!(resp.status.as_u16(), 200, "redelivery: {}", resp.text); assert_eq!( queued(&h).await, 2, "the redelivery re-ran the handler, which is what leaving it unmarked buys" ); } /// Stripe sends event types MNW never subscribed to. They reach /// `MnwEvent::Unhandled`, which is a success: acknowledged, marked so the /// redelivery is cheap, and kept out of a retry queue where they could never /// succeed. #[tokio::test] async fn an_event_type_mnw_does_not_handle_is_marked_and_not_queued() { let mut h = TestHarness::with_stripe().await; let event_id = "evt_dispatch_unhandled"; let resp = post_until_settled( &mut h, event_id, "payment_intent.created", serde_json::json!({"id": "pi_dispatch_unhandled"}), ) .await; assert_eq!( resp.status.as_u16(), 200, "an event we do not act on is still an event we accept: {}", resp.text ); assert!( processed(&h, event_id).await, "marked, so a redelivery costs one dedup read" ); assert_eq!( queued(&h).await, 0, "an unhandled type is not a failure and must not fill the retry queue" ); } /// A body with no signature header is refused before the lock, the dedup read, /// or any handler. The two "wrote nothing" assertions matter more than the /// status: this endpoint is public, and an unsigned body that got as far as the /// event lock would be a free way to make real deliveries answer 503. #[tokio::test] async fn an_unsigned_delivery_is_refused_before_the_event_lock() { let mut h = TestHarness::with_stripe().await; let event_id = "evt_dispatch_unsigned"; let payload = serde_json::json!({ "id": event_id, "type": "payment_intent.created", "data": {"object": {"id": "pi_dispatch_unsigned"}}, }) .to_string(); let resp = h .client .request_with_headers( "POST", "/stripe/webhook", Some(&payload), &[("content-type", "application/json")], ) .await; assert_eq!( resp.status.as_u16(), 400, "a body with no signature is not a Stripe event: {}", resp.text ); assert!(!processed(&h, event_id).await, "nothing was accepted"); assert_eq!(queued(&h).await, 0, "and nothing was queued"); } /// The settlement gate applies to the kinds that capture funds at checkout, and /// only those. A Fan+ session reports no `payment_status` MNW should wait on, /// because the subscription bills on its own schedule; deferring it would leave /// the subscriber paying and unsubscribed until an event that never comes. #[tokio::test] async fn a_subscription_checkout_is_not_held_back_by_the_settlement_gate() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("dispatchfan", "dispatchfan@test.com", "password123") .await; let resp = post_until_settled( &mut h, "evt_dispatch_unpaid_fanplus", "checkout.session.completed", serde_json::json!({ "id": "cs_dispatch_unpaid_fanplus", "object": "checkout.session", "subscription": "sub_dispatch_unpaid_fanplus", "customer": "cus_dispatch_unpaid_fanplus", // The state that defers a purchase. A subscription-mode session // must be finalized on it regardless. "payment_status": "unpaid", "currency": "usd", "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()}, }), ) .await; assert_eq!(resp.status.as_u16(), 200, "Fan+ checkout: {}", resp.text); let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1") .bind(user_id) .fetch_one(&h.db) .await .expect("count fan plus subscriptions"); assert_eq!( rows, 1, "a subscription-mode checkout captures nothing at checkout, so there is \ nothing to wait for and the signup must land" ); }