Skip to main content

max / makenotwork

12.0 KB · 338 lines History Blame Raw
1 //! Route-layer contract tests for `routes::stripe::webhook_v2`, the v2 thin-event
2 //! endpoint Stripe uses for Connect account state.
3 //!
4 //! A thin event carries a reference rather than a snapshot, so this handler is
5 //! the only place in the money path that answers a webhook by calling back out
6 //! to the provider. That extra hop is what the tests below pin. `stripe_webhooks`
7 //! already covers the three shallow cases (bad signature, an account event is
8 //! accepted, an unknown type is accepted); none of them observes what the
9 //! endpoint did afterwards, and all three pass against a handler that fetches
10 //! nothing and writes nothing.
11 //!
12 //! What is pinned here. The fetched account actually lands on the user row, so
13 //! a creator whose onboarding completed at Stripe stops being told to finish it.
14 //! A redelivery is a no-op at the provider as well as in the database: Stripe
15 //! sends the same event repeatedly, and a second `fetch_account` per delivery is
16 //! both a rate-limit cost and a chance to overwrite a newer state with an older
17 //! one. A fetch failure ACKs 200 but leaves the event unmarked and queued, which
18 //! is the trade `webhook_v2.rs` documents: the local retry queue owns redelivery
19 //! from that point, so an event that vanished from both places would be lost
20 //! money with no operator signal. An event whose `related_object` is missing,
21 //! and an event outside `v2.core.account`, are acknowledged without a fetch.
22 //!
23 //! Delete this file and the v2 endpoint could return 200 to everything while
24 //! fetching nothing, writing nothing, and dropping every failure on the floor.
25
26 use crate::harness::TestHarness;
27 use crate::harness::faults::stripe_unavailable;
28 use crate::harness::stripe::{TEST_WEBHOOK_SECRET_V2, sign_webhook_payload};
29 use makenotwork::db::UserId;
30
31 /// POST a signed v2 thin event of the given type and related object id.
32 async fn post_v2(
33 h: &mut TestHarness,
34 event_id: &str,
35 event_type: &str,
36 related_object: Option<&str>,
37 ) -> crate::harness::client::TestResponse {
38 let mut event = serde_json::json!({ "id": event_id, "type": event_type });
39 if let Some(acct) = related_object {
40 event["related_object"] = serde_json::json!({ "id": acct, "type": "account" });
41 }
42 let payload = event.to_string();
43 let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2);
44 h.client
45 .request_with_headers(
46 "POST",
47 "/stripe/webhook/v2",
48 Some(&payload),
49 &[
50 ("stripe-signature", signature.as_str()),
51 ("content-type", "application/json"),
52 ],
53 )
54 .await
55 }
56
57 /// A creator mid-onboarding: the account id is claimed, every capability flag is
58 /// still false. This is the state the `account.updated` event exists to change,
59 /// and starting from the mock's all-true answer would make the assertion vacuous.
60 async fn seed_pending_creator(h: &mut TestHarness, username: &str, account_id: &str) -> UserId {
61 let user_id = h
62 .signup(username, &format!("{username}@test.com"), "pass1234")
63 .await;
64 sqlx::query(
65 "UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = false, \
66 stripe_payouts_enabled = false, stripe_onboarding_complete = false WHERE id = $1",
67 )
68 .bind(user_id)
69 .bind(account_id)
70 .execute(&h.db)
71 .await
72 .expect("seed pending stripe account");
73 user_id
74 }
75
76 /// `(onboarding_complete, payouts_enabled, charges_enabled)` as stored.
77 async fn stripe_flags(h: &TestHarness, user_id: UserId) -> (bool, bool, bool) {
78 sqlx::query_as::<_, (bool, bool, bool)>(
79 "SELECT stripe_onboarding_complete, stripe_payouts_enabled, stripe_charges_enabled \
80 FROM users WHERE id = $1",
81 )
82 .bind(user_id)
83 .fetch_one(&h.db)
84 .await
85 .expect("read stripe flags")
86 }
87
88 async fn processed(h: &TestHarness, event_id: &str) -> bool {
89 sqlx::query_scalar::<_, i64>(
90 "SELECT COUNT(*) FROM processed_webhook_events WHERE event_id = $1",
91 )
92 .bind(event_id)
93 .fetch_one(&h.db)
94 .await
95 .expect("count processed markers")
96 > 0
97 }
98
99 async fn queued_failures(h: &TestHarness) -> i64 {
100 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe_v2'")
101 .fetch_one(&h.db)
102 .await
103 .expect("count queued v2 failures")
104 }
105
106 /// The number of times the provider was asked for an account.
107 fn fetches(h: &TestHarness) -> u32 {
108 h.mock_stripe
109 .as_ref()
110 .expect("with_mocks provides a payment provider")
111 .faults()
112 .calls("fetch_account")
113 }
114
115 /// The point of the v2 hop: the fetched account is written to the user row.
116 ///
117 /// The handler returning 200 proves nothing on its own; a handler that parsed
118 /// the event and stopped would also return 200. This asserts the three
119 /// capability flags the dashboard reads, from false to true.
120 #[tokio::test]
121 async fn a_v2_account_event_writes_the_fetched_state_to_the_creator() {
122 let mut h = TestHarness::with_mocks().await;
123 let user_id = seed_pending_creator(&mut h, "v2creator", "acct_v2_write").await;
124
125 let resp = post_v2(
126 &mut h,
127 "evt_v2_write_001",
128 "v2.core.account.updated",
129 Some("acct_v2_write"),
130 )
131 .await;
132 assert_eq!(resp.status.as_u16(), 200, "acknowledged: {}", resp.text);
133
134 assert_eq!(
135 stripe_flags(&h, user_id).await,
136 (true, true, true),
137 "the account fetched for the thin event must land on the user row"
138 );
139 assert_eq!(fetches(&h), 1, "exactly one fetch for one delivery");
140 assert!(
141 processed(&h, "evt_v2_write_001").await,
142 "a succeeded event is marked, or every redelivery re-runs it"
143 );
144 }
145
146 /// Stripe delivers at least once. The second delivery must not reach the
147 /// provider: a fetch per redelivery burns rate limit and can write an account
148 /// snapshot older than the one already stored.
149 #[tokio::test]
150 async fn a_redelivered_v2_event_does_not_refetch_the_account() {
151 let mut h = TestHarness::with_mocks().await;
152 seed_pending_creator(&mut h, "v2replay", "acct_v2_replay").await;
153
154 for delivery in 1..=3 {
155 // 503 is the handler's documented answer to a delivery that arrives
156 // while another is still in flight, and Stripe's answer to a 503 is to
157 // send it again. The advisory lock is released by the rollback of the
158 // previous delivery's transaction, which the pool completes just after
159 // the response, so back-to-back deliveries can legitimately see it held.
160 // Retrying is what the endpoint asks its caller to do.
161 let mut resp = post_v2(
162 &mut h,
163 "evt_v2_replay_001",
164 "v2.core.account.updated",
165 Some("acct_v2_replay"),
166 )
167 .await;
168 for _ in 0..20 {
169 if resp.status.as_u16() != 503 {
170 break;
171 }
172 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
173 resp = post_v2(
174 &mut h,
175 "evt_v2_replay_001",
176 "v2.core.account.updated",
177 Some("acct_v2_replay"),
178 )
179 .await;
180 }
181 assert_eq!(
182 resp.status.as_u16(),
183 200,
184 "delivery {delivery} is ACKed: {}",
185 resp.text
186 );
187 }
188
189 assert_eq!(
190 fetches(&h),
191 1,
192 "three deliveries of one event id are one unit of work"
193 );
194 }
195
196 /// The documented trade: a failed fetch ACKs 200 so Stripe stops retrying, and
197 /// the event goes to the local queue instead. Both halves matter. Losing the
198 /// queue row would drop a money event with no operator signal; marking the event
199 /// processed would stop the queue's own retry from ever re-running it.
200 #[tokio::test]
201 async fn a_failed_fetch_is_queued_and_left_unprocessed() {
202 let mut h = TestHarness::with_mocks().await;
203 seed_pending_creator(&mut h, "v2fail", "acct_v2_fail").await;
204
205 h.mock_stripe
206 .as_ref()
207 .expect("with_mocks provides a payment provider")
208 .faults()
209 .fail_always("fetch_account", stripe_unavailable);
210
211 let resp = post_v2(
212 &mut h,
213 "evt_v2_fail_001",
214 "v2.core.account.updated",
215 Some("acct_v2_fail"),
216 )
217 .await;
218 assert_eq!(
219 resp.status.as_u16(),
220 200,
221 "the in-house queue owns retry from here, so Stripe is ACKed: {}",
222 resp.text
223 );
224 assert_eq!(
225 queued_failures(&h).await,
226 1,
227 "the event must be recoverable from the local queue"
228 );
229 assert!(
230 !processed(&h, "evt_v2_fail_001").await,
231 "marking a failed event processed would make the queued retry a no-op"
232 );
233 }
234
235 /// A thin event with nothing to fetch is acknowledged rather than retried
236 /// forever, and never reaches the provider.
237 #[tokio::test]
238 async fn a_v2_account_event_without_a_related_object_is_acknowledged_without_a_fetch() {
239 let mut h = TestHarness::with_mocks().await;
240
241 let resp = post_v2(&mut h, "evt_v2_bare_001", "v2.core.account.updated", None).await;
242 assert_eq!(resp.status.as_u16(), 200, "nothing to do is not an error");
243 assert_eq!(fetches(&h), 0, "there is no object to fetch");
244 assert_eq!(
245 queued_failures(&h).await,
246 0,
247 "an unfetchable event is not a failure to retry"
248 );
249 assert!(
250 processed(&h, "evt_v2_bare_001").await,
251 "acknowledged means marked, so a redelivery short-circuits"
252 );
253 }
254
255 /// Everything outside `v2.core.account` is out of scope for this endpoint. It is
256 /// acknowledged so Stripe stops sending it, and it must not call the provider.
257 #[tokio::test]
258 async fn a_non_account_v2_event_is_acknowledged_without_a_fetch() {
259 let mut h = TestHarness::with_mocks().await;
260
261 let resp = post_v2(
262 &mut h,
263 "evt_v2_other_001",
264 "v2.billing.meter.no_meter_found",
265 Some("acct_v2_other"),
266 )
267 .await;
268 assert_eq!(resp.status.as_u16(), 200, "unhandled is not unaccepted");
269 assert_eq!(fetches(&h), 0, "an unhandled type fetches nothing");
270 assert!(
271 processed(&h, "evt_v2_other_001").await,
272 "acknowledged means marked"
273 );
274 }
275
276 /// No signature header at all is refused before any parsing, the same as a wrong
277 /// one. Without this, an unsigned body reaching the parser would be one bug away
278 /// from an unauthenticated write to the account path.
279 #[tokio::test]
280 async fn an_unsigned_v2_delivery_is_refused() {
281 let mut h = TestHarness::with_mocks().await;
282
283 let payload = r#"{"id":"evt_v2_nosig","type":"v2.core.account.updated","related_object":{"id":"acct_x","type":"account"}}"#;
284 let resp = h
285 .client
286 .request_with_headers(
287 "POST",
288 "/stripe/webhook/v2",
289 Some(payload),
290 &[("content-type", "application/json")],
291 )
292 .await;
293
294 assert_eq!(
295 resp.status.as_u16(),
296 400,
297 "a body with no signature is not a Stripe event: {}",
298 resp.text
299 );
300 assert_eq!(fetches(&h), 0, "refused before the provider is touched");
301 assert!(!processed(&h, "evt_v2_nosig").await, "nothing was accepted");
302 }
303
304 /// A correctly signed body that is not a thin event is a 400 rather than a
305 /// silent 200: the signature proves it came from Stripe, so a shape we cannot
306 /// parse is a schema change worth surfacing, not traffic to swallow.
307 #[tokio::test]
308 async fn a_signed_body_that_is_not_a_thin_event_is_refused() {
309 let mut h = TestHarness::with_mocks().await;
310
311 let payload = r#"{"not_an_event":true}"#;
312 let signature = sign_webhook_payload(payload, TEST_WEBHOOK_SECRET_V2);
313 let resp = h
314 .client
315 .request_with_headers(
316 "POST",
317 "/stripe/webhook/v2",
318 Some(payload),
319 &[
320 ("stripe-signature", signature.as_str()),
321 ("content-type", "application/json"),
322 ],
323 )
324 .await;
325
326 assert_eq!(
327 resp.status.as_u16(),
328 400,
329 "an unparseable thin event is refused: {}",
330 resp.text
331 );
332 assert_eq!(
333 fetches(&h),
334 0,
335 "nothing to fetch from a shape we cannot read"
336 );
337 }
338