Skip to main content

max / makenotwork

12.6 KB · 351 lines History Blame Raw
1 //! Route-layer contract tests for `routes::stripe::webhook`, the v1 dispatcher:
2 //! the envelope handling every Stripe event passes through before any handler
3 //! sees it.
4 //!
5 //! Its siblings cover what the handlers do. `stripe_webhook_exactly_once` owns
6 //! the checkout half of the exactly-once protocol, `stripe_webhook_billing_replay`
7 //! the invoice half, and `stripe_webhooks` drives one delivery of each event type
8 //! through to its effect. What none of them assert is the envelope's own
9 //! behaviour, which is where an event is lost rather than mishandled.
10 //!
11 //! Four things only this file pins.
12 //!
13 //! The event lock. A second delivery arriving while the first is mid-flight is
14 //! answered 503 rather than parked on a pooled connection, and the sibling
15 //! suites treat that 503 as timing noise to retry through. Here it is the
16 //! subject: the lock is taken by the test, so the contended path is reached on
17 //! purpose instead of by luck, and the delivery that loses must write nothing at
18 //! all.
19 //!
20 //! The failure path. A handler error ACKs 200 (the local retry queue owns
21 //! redelivery from that point) while leaving the event *unmarked*, so both the
22 //! queue worker and a Stripe redelivery still re-run it. `stripe_webhooks` checks
23 //! the queue row; the unmarked half is the one that decides whether a retry can
24 //! ever happen, and nothing checked it.
25 //!
26 //! The unhandled arm. `MnwEvent::Unhandled` must be acknowledged and marked, not
27 //! queued: Stripe sends event types MNW never asked for, and treating one as a
28 //! failure would fill the retry queue with work that can never succeed.
29 //!
30 //! The settlement gate's other side. `dispatch_checkout_session` defers a
31 //! funds-capturing checkout until it settles; the subscription-mode kinds must
32 //! NOT be deferred, since they capture nothing at checkout and the subscription
33 //! bills separately. `stripe_webhook_exactly_once` pins the deferral. A gate
34 //! that deferred everything would pass that test and silently stop every Fan+
35 //! and creator-tier signup whose provider reported `unpaid`.
36
37 use crate::harness::TestHarness;
38 use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
39 use makenotwork::db::UserId;
40
41 /// Sign an event envelope and POST it, without the 503 retry loop the sibling
42 /// suites use: this file is asserting on that status, so it must not swallow it.
43 async fn post_once(
44 h: &mut TestHarness,
45 event_id: &str,
46 event_type: &str,
47 object: serde_json::Value,
48 ) -> crate::harness::client::TestResponse {
49 let payload = serde_json::json!({
50 "id": event_id,
51 "type": event_type,
52 "data": {"object": object},
53 })
54 .to_string();
55 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
56 h.client
57 .request_with_headers(
58 "POST",
59 "/stripe/webhook",
60 Some(&payload),
61 &[
62 ("stripe-signature", signature.as_str()),
63 ("content-type", "application/json"),
64 ],
65 )
66 .await
67 }
68
69 /// Retry through the transient 503 the way Stripe does, for the deliveries a
70 /// test wants to succeed rather than to observe.
71 async fn post_until_settled(
72 h: &mut TestHarness,
73 event_id: &str,
74 event_type: &str,
75 object: serde_json::Value,
76 ) -> crate::harness::client::TestResponse {
77 for _ in 0..200 {
78 let resp = post_once(h, event_id, event_type, object.clone()).await;
79 if resp.status != 503 {
80 return resp;
81 }
82 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
83 }
84 panic!("{event_id}: the webhook route answered 503 for two seconds");
85 }
86
87 async fn processed(h: &TestHarness, event_id: &str) -> bool {
88 sqlx::query_scalar::<_, i64>(
89 "SELECT COUNT(*) FROM processed_webhook_events WHERE event_id = $1",
90 )
91 .bind(event_id)
92 .fetch_one(&h.db)
93 .await
94 .expect("count processed markers")
95 > 0
96 }
97
98 async fn queued(h: &TestHarness) -> i64 {
99 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe'")
100 .fetch_one(&h.db)
101 .await
102 .expect("count queued retries")
103 }
104
105 /// A checkout session with no `checkout_type`, which routes to the purchase
106 /// handler, and no `buyer_id`, which that handler requires. The cheapest way to
107 /// make a handler fail for reasons the envelope has to cope with.
108 fn unroutable_purchase(session_id: &str, seller_id: UserId) -> serde_json::Value {
109 serde_json::json!({
110 "id": session_id,
111 "object": "checkout_session",
112 "mode": "payment",
113 "payment_status": "paid",
114 "metadata": {"seller_id": seller_id.to_string()},
115 "payment_intent": format!("pi_{session_id}"),
116 })
117 }
118
119 /// The contended path, reached deliberately. The test holds the same advisory
120 /// lock the handler takes, so the delivery below is guaranteed to lose it.
121 ///
122 /// Two halves. The loser must be told to redeliver rather than made to wait on a
123 /// pooled connection, and it must leave nothing behind: a 503 that had already
124 /// marked the event would turn a transient collision into a permanently skipped
125 /// event. Then, with the lock released, the same delivery must go through, which
126 /// is what makes the 503 a deferral rather than a refusal.
127 #[tokio::test]
128 async fn a_delivery_that_loses_the_event_lock_is_deferred_and_writes_nothing() {
129 let mut h = TestHarness::with_stripe().await;
130 let event_id = "evt_dispatch_contended";
131
132 // The lock transaction borrows the pool it came from, so it takes its own
133 // handle rather than `h.db`: the harness client needs `&mut h` while the
134 // lock is still held, which is the whole point of the test.
135 let pool = h.db.clone();
136 let held = makenotwork::db::webhook_events::try_lock_event(&pool, event_id)
137 .await
138 .expect("take the event lock")
139 .expect("the lock is free before the test takes it");
140
141 let resp = post_once(
142 &mut h,
143 event_id,
144 "payment_intent.created",
145 serde_json::json!({"id": "pi_dispatch_contended"}),
146 )
147 .await;
148 assert_eq!(
149 resp.status.as_u16(),
150 503,
151 "a delivery that loses the lock is told to come back: {}",
152 resp.text
153 );
154 assert!(
155 !processed(&h, event_id).await,
156 "a deferred delivery must not mark the event, or the redelivery it just \
157 asked for would be skipped"
158 );
159 assert_eq!(
160 queued(&h).await,
161 0,
162 "a deferred delivery is not a failed one and does not enter the retry queue"
163 );
164
165 // Releasing the lock is the whole difference; nothing else about the
166 // delivery changes.
167 held.rollback().await.expect("release the event lock");
168
169 let resp = post_until_settled(
170 &mut h,
171 event_id,
172 "payment_intent.created",
173 serde_json::json!({"id": "pi_dispatch_contended"}),
174 )
175 .await;
176 assert_eq!(
177 resp.status.as_u16(),
178 200,
179 "the same delivery goes through once the lock is free: {}",
180 resp.text
181 );
182 assert!(
183 processed(&h, event_id).await,
184 "and is marked, so the next redelivery short-circuits"
185 );
186 }
187
188 /// A handler error is ACKed so Stripe stops its own redelivery schedule, and the
189 /// event is queued locally instead. The event must NOT be marked: the whole
190 /// point of the queue is that the work still has to happen, and a marked event
191 /// short-circuits both the queue worker's re-run and any Stripe redelivery.
192 #[tokio::test]
193 async fn a_failed_handler_is_queued_and_deliberately_left_unmarked() {
194 let mut h = TestHarness::with_stripe().await;
195 let seller_id = h
196 .signup("dispatchseller", "dispatchseller@test.com", "password123")
197 .await;
198 let event_id = "evt_dispatch_failed";
199
200 let resp = post_until_settled(
201 &mut h,
202 event_id,
203 "checkout.session.completed",
204 unroutable_purchase("cs_dispatch_failed", seller_id),
205 )
206 .await;
207 assert_eq!(
208 resp.status.as_u16(),
209 200,
210 "the local queue owns retry from here, so Stripe is ACKed: {}",
211 resp.text
212 );
213 assert_eq!(
214 queued(&h).await,
215 1,
216 "the event is recoverable from the queue"
217 );
218 assert!(
219 !processed(&h, event_id).await,
220 "marking a failed event would make both retry routes a no-op"
221 );
222
223 // The unmarked half, demonstrated rather than asserted about: a redelivery
224 // of the same event id re-enters the handler instead of short-circuiting.
225 let resp = post_until_settled(
226 &mut h,
227 event_id,
228 "checkout.session.completed",
229 unroutable_purchase("cs_dispatch_failed", seller_id),
230 )
231 .await;
232 assert_eq!(resp.status.as_u16(), 200, "redelivery: {}", resp.text);
233 assert_eq!(
234 queued(&h).await,
235 2,
236 "the redelivery re-ran the handler, which is what leaving it unmarked buys"
237 );
238 }
239
240 /// Stripe sends event types MNW never subscribed to. They reach
241 /// `MnwEvent::Unhandled`, which is a success: acknowledged, marked so the
242 /// redelivery is cheap, and kept out of a retry queue where they could never
243 /// succeed.
244 #[tokio::test]
245 async fn an_event_type_mnw_does_not_handle_is_marked_and_not_queued() {
246 let mut h = TestHarness::with_stripe().await;
247 let event_id = "evt_dispatch_unhandled";
248
249 let resp = post_until_settled(
250 &mut h,
251 event_id,
252 "payment_intent.created",
253 serde_json::json!({"id": "pi_dispatch_unhandled"}),
254 )
255 .await;
256
257 assert_eq!(
258 resp.status.as_u16(),
259 200,
260 "an event we do not act on is still an event we accept: {}",
261 resp.text
262 );
263 assert!(
264 processed(&h, event_id).await,
265 "marked, so a redelivery costs one dedup read"
266 );
267 assert_eq!(
268 queued(&h).await,
269 0,
270 "an unhandled type is not a failure and must not fill the retry queue"
271 );
272 }
273
274 /// A body with no signature header is refused before the lock, the dedup read,
275 /// or any handler. The two "wrote nothing" assertions matter more than the
276 /// status: this endpoint is public, and an unsigned body that got as far as the
277 /// event lock would be a free way to make real deliveries answer 503.
278 #[tokio::test]
279 async fn an_unsigned_delivery_is_refused_before_the_event_lock() {
280 let mut h = TestHarness::with_stripe().await;
281 let event_id = "evt_dispatch_unsigned";
282
283 let payload = serde_json::json!({
284 "id": event_id,
285 "type": "payment_intent.created",
286 "data": {"object": {"id": "pi_dispatch_unsigned"}},
287 })
288 .to_string();
289 let resp = h
290 .client
291 .request_with_headers(
292 "POST",
293 "/stripe/webhook",
294 Some(&payload),
295 &[("content-type", "application/json")],
296 )
297 .await;
298
299 assert_eq!(
300 resp.status.as_u16(),
301 400,
302 "a body with no signature is not a Stripe event: {}",
303 resp.text
304 );
305 assert!(!processed(&h, event_id).await, "nothing was accepted");
306 assert_eq!(queued(&h).await, 0, "and nothing was queued");
307 }
308
309 /// The settlement gate applies to the kinds that capture funds at checkout, and
310 /// only those. A Fan+ session reports no `payment_status` MNW should wait on,
311 /// because the subscription bills on its own schedule; deferring it would leave
312 /// the subscriber paying and unsubscribed until an event that never comes.
313 #[tokio::test]
314 async fn a_subscription_checkout_is_not_held_back_by_the_settlement_gate() {
315 let mut h = TestHarness::with_mocks().await;
316 let user_id = h
317 .signup("dispatchfan", "dispatchfan@test.com", "password123")
318 .await;
319
320 let resp = post_until_settled(
321 &mut h,
322 "evt_dispatch_unpaid_fanplus",
323 "checkout.session.completed",
324 serde_json::json!({
325 "id": "cs_dispatch_unpaid_fanplus",
326 "object": "checkout.session",
327 "subscription": "sub_dispatch_unpaid_fanplus",
328 "customer": "cus_dispatch_unpaid_fanplus",
329 // The state that defers a purchase. A subscription-mode session
330 // must be finalized on it regardless.
331 "payment_status": "unpaid",
332 "currency": "usd",
333 "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()},
334 }),
335 )
336 .await;
337 assert_eq!(resp.status.as_u16(), 200, "Fan+ checkout: {}", resp.text);
338
339 let rows: i64 =
340 sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1")
341 .bind(user_id)
342 .fetch_one(&h.db)
343 .await
344 .expect("count fan plus subscriptions");
345 assert_eq!(
346 rows, 1,
347 "a subscription-mode checkout captures nothing at checkout, so there is \
348 nothing to wait for and the signup must land"
349 );
350 }
351