//! Route-layer contract tests for `routes::stripe::webhook_v2`, the v2 thin-event //! endpoint Stripe uses for Connect account state. //! //! A thin event carries a reference rather than a snapshot, so this handler is //! the only place in the money path that answers a webhook by calling back out //! to the provider. That extra hop is what the tests below pin. `stripe_webhooks` //! already covers the three shallow cases (bad signature, an account event is //! accepted, an unknown type is accepted); none of them observes what the //! endpoint did afterwards, and all three pass against a handler that fetches //! nothing and writes nothing. //! //! What is pinned here. The fetched account actually lands on the user row, so //! a creator whose onboarding completed at Stripe stops being told to finish it. //! A redelivery is a no-op at the provider as well as in the database: Stripe //! sends the same event repeatedly, and a second `fetch_account` per delivery is //! both a rate-limit cost and a chance to overwrite a newer state with an older //! one. A fetch failure ACKs 200 but leaves the event unmarked and queued, which //! is the trade `webhook_v2.rs` documents: the local retry queue owns redelivery //! from that point, so an event that vanished from both places would be lost //! money with no operator signal. An event whose `related_object` is missing, //! and an event outside `v2.core.account`, are acknowledged without a fetch. //! //! Delete this file and the v2 endpoint could return 200 to everything while //! fetching nothing, writing nothing, and dropping every failure on the floor. use crate::harness::TestHarness; use crate::harness::faults::stripe_unavailable; use crate::harness::stripe::{TEST_WEBHOOK_SECRET_V2, sign_webhook_payload}; use makenotwork::db::UserId; /// POST a signed v2 thin event of the given type and related object id. async fn post_v2( h: &mut TestHarness, event_id: &str, event_type: &str, related_object: Option<&str>, ) -> crate::harness::client::TestResponse { let mut event = serde_json::json!({ "id": event_id, "type": event_type }); if let Some(acct) = related_object { event["related_object"] = serde_json::json!({ "id": acct, "type": "account" }); } let payload = event.to_string(); let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2); h.client .request_with_headers( "POST", "/stripe/webhook/v2", Some(&payload), &[ ("stripe-signature", signature.as_str()), ("content-type", "application/json"), ], ) .await } /// A creator mid-onboarding: the account id is claimed, every capability flag is /// still false. This is the state the `account.updated` event exists to change, /// and starting from the mock's all-true answer would make the assertion vacuous. async fn seed_pending_creator(h: &mut TestHarness, username: &str, account_id: &str) -> UserId { let user_id = h .signup(username, &format!("{username}@test.com"), "pass1234") .await; sqlx::query( "UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = false, \ stripe_payouts_enabled = false, stripe_onboarding_complete = false WHERE id = $1", ) .bind(user_id) .bind(account_id) .execute(&h.db) .await .expect("seed pending stripe account"); user_id } /// `(onboarding_complete, payouts_enabled, charges_enabled)` as stored. async fn stripe_flags(h: &TestHarness, user_id: UserId) -> (bool, bool, bool) { sqlx::query_as::<_, (bool, bool, bool)>( "SELECT stripe_onboarding_complete, stripe_payouts_enabled, stripe_charges_enabled \ FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(&h.db) .await .expect("read stripe flags") } 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_failures(h: &TestHarness) -> i64 { sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe_v2'") .fetch_one(&h.db) .await .expect("count queued v2 failures") } /// The number of times the provider was asked for an account. fn fetches(h: &TestHarness) -> u32 { h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .calls("fetch_account") } /// The point of the v2 hop: the fetched account is written to the user row. /// /// The handler returning 200 proves nothing on its own; a handler that parsed /// the event and stopped would also return 200. This asserts the three /// capability flags the dashboard reads, from false to true. #[tokio::test] async fn a_v2_account_event_writes_the_fetched_state_to_the_creator() { let mut h = TestHarness::with_mocks().await; let user_id = seed_pending_creator(&mut h, "v2creator", "acct_v2_write").await; let resp = post_v2( &mut h, "evt_v2_write_001", "v2.core.account.updated", Some("acct_v2_write"), ) .await; assert_eq!(resp.status.as_u16(), 200, "acknowledged: {}", resp.text); assert_eq!( stripe_flags(&h, user_id).await, (true, true, true), "the account fetched for the thin event must land on the user row" ); assert_eq!(fetches(&h), 1, "exactly one fetch for one delivery"); assert!( processed(&h, "evt_v2_write_001").await, "a succeeded event is marked, or every redelivery re-runs it" ); } /// Stripe delivers at least once. The second delivery must not reach the /// provider: a fetch per redelivery burns rate limit and can write an account /// snapshot older than the one already stored. #[tokio::test] async fn a_redelivered_v2_event_does_not_refetch_the_account() { let mut h = TestHarness::with_mocks().await; seed_pending_creator(&mut h, "v2replay", "acct_v2_replay").await; for delivery in 1..=3 { // 503 is the handler's documented answer to a delivery that arrives // while another is still in flight, and Stripe's answer to a 503 is to // send it again. The advisory lock is released by the rollback of the // previous delivery's transaction, which the pool completes just after // the response, so back-to-back deliveries can legitimately see it held. // Retrying is what the endpoint asks its caller to do. let mut resp = post_v2( &mut h, "evt_v2_replay_001", "v2.core.account.updated", Some("acct_v2_replay"), ) .await; for _ in 0..20 { if resp.status.as_u16() != 503 { break; } tokio::time::sleep(std::time::Duration::from_millis(25)).await; resp = post_v2( &mut h, "evt_v2_replay_001", "v2.core.account.updated", Some("acct_v2_replay"), ) .await; } assert_eq!( resp.status.as_u16(), 200, "delivery {delivery} is ACKed: {}", resp.text ); } assert_eq!( fetches(&h), 1, "three deliveries of one event id are one unit of work" ); } /// The documented trade: a failed fetch ACKs 200 so Stripe stops retrying, and /// the event goes to the local queue instead. Both halves matter. Losing the /// queue row would drop a money event with no operator signal; marking the event /// processed would stop the queue's own retry from ever re-running it. #[tokio::test] async fn a_failed_fetch_is_queued_and_left_unprocessed() { let mut h = TestHarness::with_mocks().await; seed_pending_creator(&mut h, "v2fail", "acct_v2_fail").await; h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .fail_always("fetch_account", stripe_unavailable); let resp = post_v2( &mut h, "evt_v2_fail_001", "v2.core.account.updated", Some("acct_v2_fail"), ) .await; assert_eq!( resp.status.as_u16(), 200, "the in-house queue owns retry from here, so Stripe is ACKed: {}", resp.text ); assert_eq!( queued_failures(&h).await, 1, "the event must be recoverable from the local queue" ); assert!( !processed(&h, "evt_v2_fail_001").await, "marking a failed event processed would make the queued retry a no-op" ); } /// A thin event with nothing to fetch is acknowledged rather than retried /// forever, and never reaches the provider. #[tokio::test] async fn a_v2_account_event_without_a_related_object_is_acknowledged_without_a_fetch() { let mut h = TestHarness::with_mocks().await; let resp = post_v2(&mut h, "evt_v2_bare_001", "v2.core.account.updated", None).await; assert_eq!(resp.status.as_u16(), 200, "nothing to do is not an error"); assert_eq!(fetches(&h), 0, "there is no object to fetch"); assert_eq!( queued_failures(&h).await, 0, "an unfetchable event is not a failure to retry" ); assert!( processed(&h, "evt_v2_bare_001").await, "acknowledged means marked, so a redelivery short-circuits" ); } /// Everything outside `v2.core.account` is out of scope for this endpoint. It is /// acknowledged so Stripe stops sending it, and it must not call the provider. #[tokio::test] async fn a_non_account_v2_event_is_acknowledged_without_a_fetch() { let mut h = TestHarness::with_mocks().await; let resp = post_v2( &mut h, "evt_v2_other_001", "v2.billing.meter.no_meter_found", Some("acct_v2_other"), ) .await; assert_eq!(resp.status.as_u16(), 200, "unhandled is not unaccepted"); assert_eq!(fetches(&h), 0, "an unhandled type fetches nothing"); assert!( processed(&h, "evt_v2_other_001").await, "acknowledged means marked" ); } /// No signature header at all is refused before any parsing, the same as a wrong /// one. Without this, an unsigned body reaching the parser would be one bug away /// from an unauthenticated write to the account path. #[tokio::test] async fn an_unsigned_v2_delivery_is_refused() { let mut h = TestHarness::with_mocks().await; let payload = r#"{"id":"evt_v2_nosig","type":"v2.core.account.updated","related_object":{"id":"acct_x","type":"account"}}"#; let resp = h .client .request_with_headers( "POST", "/stripe/webhook/v2", 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_eq!(fetches(&h), 0, "refused before the provider is touched"); assert!(!processed(&h, "evt_v2_nosig").await, "nothing was accepted"); } /// A correctly signed body that is not a thin event is a 400 rather than a /// silent 200: the signature proves it came from Stripe, so a shape we cannot /// parse is a schema change worth surfacing, not traffic to swallow. #[tokio::test] async fn a_signed_body_that_is_not_a_thin_event_is_refused() { let mut h = TestHarness::with_mocks().await; let payload = r#"{"not_an_event":true}"#; let signature = sign_webhook_payload(payload, TEST_WEBHOOK_SECRET_V2); let resp = h .client .request_with_headers( "POST", "/stripe/webhook/v2", Some(payload), &[ ("stripe-signature", signature.as_str()), ("content-type", "application/json"), ], ) .await; assert_eq!( resp.status.as_u16(), 400, "an unparseable thin event is refused: {}", resp.text ); assert_eq!( fetches(&h), 0, "nothing to fetch from a shape we cannot read" ); }