Skip to main content

max / makenotwork

16.1 KB · 457 lines History Blame Raw
1 //! Crash-recovery integration tests for the purchase webhook finalizer
2 //! (ultra-fuzz Payments A+, finding M-Pay1).
3 //!
4 //! A `checkout.session.completed` first attempt can crash AFTER committing the
5 //! completed transaction status but BEFORE running the secondary effects
6 //! (license-key mint, revenue splits, emails). Because the event is not marked
7 //! processed until the handler returns Ok, Stripe redelivers. On redelivery the
8 //! transaction is already completed, so `complete_transaction` returns None; the
9 //! recovery branch must re-fetch the completed rows and re-run the idempotent
10 //! finalizer so the buyer ends up with their key and the collaborators with
11 //! their splits. Running it again must NOT create duplicates.
12
13 use crate::harness::TestHarness;
14 use makenotwork::db;
15 use serde_json::Value;
16 use std::collections::HashMap;
17
18 /// Create a Stripe-connected creator with a published paid item that has license
19 /// keys enabled. Returns (seller_id, project_id, item_id).
20 async fn setup_keyed_item(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) {
21 let seller_id = h.signup("kseller", "kseller@test.com", "pass1234").await;
22 h.grant_creator(seller_id).await;
23 sqlx::query("UPDATE users SET stripe_account_id = 'acct_mock_kseller', stripe_charges_enabled = true WHERE id = $1")
24 .bind(seller_id)
25 .execute(&h.db)
26 .await
27 .unwrap();
28
29 h.client.post_form("/logout", "").await;
30 h.login("kseller", "pass1234").await;
31
32 let resp = h
33 .client
34 .post_form("/api/projects", "slug=kshop&title=KShop")
35 .await;
36 let project: Value = resp.json();
37 let project_id = project["id"].as_str().unwrap().to_string();
38
39 let resp = h
40 .client
41 .post_form(
42 &format!("/api/projects/{project_id}/items"),
43 &format!("title=Plugin&price_cents={price_cents}&item_type=plugin"),
44 )
45 .await;
46 let item: Value = resp.json();
47 let item_id = item["id"].as_str().unwrap().to_string();
48
49 // Enable license keys so finalize mints one on purchase.
50 h.client
51 .put_form(
52 &format!("/api/items/{item_id}/license-settings"),
53 "enable_license_keys=on&default_max_activations=3",
54 )
55 .await;
56
57 h.client
58 .put_form(&format!("/api/projects/{project_id}"), "is_public=true")
59 .await;
60 h.client
61 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
62 .await;
63
64 (seller_id, project_id, item_id)
65 }
66
67 /// Deliver a `checkout.session.completed` webhook for a single-item purchase.
68 async fn deliver_purchase_webhook(
69 h: &mut TestHarness,
70 event_id: &str,
71 session_id: &str,
72 buyer_id: db::UserId,
73 seller_id: db::UserId,
74 item_id: &str,
75 ) -> u16 {
76 let mut meta = HashMap::new();
77 meta.insert("buyer_id".to_string(), buyer_id.to_string());
78 meta.insert("seller_id".to_string(), seller_id.to_string());
79 meta.insert("item_id".to_string(), item_id.to_string());
80 let session = serde_json::json!({
81 "id": session_id,
82 "object": "checkout_session",
83 "mode": "payment",
84 "metadata": meta,
85 "payment_intent": "pi_crash_recovery",
86 });
87 let payload = serde_json::json!({
88 "id": event_id,
89 "type": "checkout.session.completed",
90 "data": {"object": session},
91 })
92 .to_string();
93 let signature = crate::harness::stripe::sign_webhook_payload(
94 &payload,
95 crate::harness::stripe::TEST_WEBHOOK_SECRET,
96 );
97 h.client
98 .request_with_headers(
99 "POST",
100 "/stripe/webhook",
101 Some(&payload),
102 &[
103 ("stripe-signature", &signature),
104 ("content-type", "application/json"),
105 ],
106 )
107 .await
108 .status
109 .as_u16()
110 }
111
112 #[tokio::test]
113 async fn crash_recovery_reruns_finalize_idempotently() {
114 let mut h = TestHarness::with_mocks().await;
115 let (seller_id, project_id, item_id) = setup_keyed_item(&mut h, 1000).await;
116
117 // Collaborator with a 50% split.
118 let collab_id = h.signup("kcollab", "kcollab@test.com", "pass1234").await;
119 h.client.post_form("/logout", "").await;
120 sqlx::query(
121 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1::uuid, $2, 'member', 50, $3)",
122 )
123 .bind(&project_id)
124 .bind(collab_id)
125 .bind(seller_id)
126 .execute(&h.db)
127 .await
128 .unwrap();
129
130 // Buyer initiates checkout (creates the pending transaction).
131 let buyer_id = h.signup("kbuyer", "kbuyer@test.com", "pass1234").await;
132 h.client
133 .post_form(
134 &format!("/stripe/checkout/{item_id}"),
135 "share_contact=false",
136 )
137 .await;
138 let session_id: String = sqlx::query_scalar(
139 "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
140 )
141 .bind(buyer_id)
142 .fetch_one(&h.db)
143 .await
144 .unwrap();
145
146 // First delivery completes the purchase and runs finalize normally.
147 let status = deliver_purchase_webhook(
148 &mut h,
149 "evt_crash_1",
150 &session_id,
151 buyer_id,
152 seller_id,
153 &item_id,
154 )
155 .await;
156 assert_eq!(status, 200, "first webhook should succeed");
157 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
158
159 let tx_id: db::TransactionId =
160 sqlx::query_scalar("SELECT id FROM transactions WHERE stripe_checkout_session_id = $1")
161 .bind(&session_id)
162 .fetch_one(&h.db)
163 .await
164 .unwrap();
165
166 // --- Simulate a first attempt that crashed before finalize: the tx is
167 // completed but the secondary effects never landed. Wipe key + splits. ---
168 sqlx::query("DELETE FROM license_keys WHERE transaction_id = $1")
169 .bind(tx_id)
170 .execute(&h.db)
171 .await
172 .unwrap();
173 sqlx::query("DELETE FROM revenue_splits WHERE transaction_id = $1")
174 .bind(tx_id)
175 .execute(&h.db)
176 .await
177 .unwrap();
178
179 let key_count: i64 =
180 sqlx::query_scalar("SELECT COUNT(*) FROM license_keys WHERE transaction_id = $1")
181 .bind(tx_id)
182 .fetch_one(&h.db)
183 .await
184 .unwrap();
185 assert_eq!(key_count, 0, "precondition: key wiped");
186 let split_count: i64 =
187 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE transaction_id = $1")
188 .bind(tx_id)
189 .fetch_one(&h.db)
190 .await
191 .unwrap();
192 assert_eq!(split_count, 0, "precondition: splits wiped");
193
194 // --- Redelivery (new event id, so dedup passes; the tx is already completed
195 // so complete_transaction returns None -> the recovery branch re-runs the
196 // idempotent finalizer). ---
197 let status = deliver_purchase_webhook(
198 &mut h,
199 "evt_crash_2",
200 &session_id,
201 buyer_id,
202 seller_id,
203 &item_id,
204 )
205 .await;
206 assert_eq!(status, 200, "recovery webhook should succeed");
207 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
208
209 let key_count: i64 =
210 sqlx::query_scalar("SELECT COUNT(*) FROM license_keys WHERE transaction_id = $1")
211 .bind(tx_id)
212 .fetch_one(&h.db)
213 .await
214 .unwrap();
215 assert_eq!(key_count, 1, "recovery should mint the missing license key");
216 let split_count: i64 =
217 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE transaction_id = $1")
218 .bind(tx_id)
219 .fetch_one(&h.db)
220 .await
221 .unwrap();
222 assert_eq!(split_count, 1, "recovery should record the missing split");
223
224 // --- A SECOND recovery (another redelivery) must NOT create duplicates. ---
225 let status = deliver_purchase_webhook(
226 &mut h,
227 "evt_crash_3",
228 &session_id,
229 buyer_id,
230 seller_id,
231 &item_id,
232 )
233 .await;
234 assert_eq!(status, 200, "second recovery webhook should succeed");
235 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
236
237 let key_count: i64 =
238 sqlx::query_scalar("SELECT COUNT(*) FROM license_keys WHERE transaction_id = $1")
239 .bind(tx_id)
240 .fetch_one(&h.db)
241 .await
242 .unwrap();
243 assert_eq!(key_count, 1, "idempotent: still exactly one license key");
244 let split_count: i64 =
245 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE transaction_id = $1")
246 .bind(tx_id)
247 .fetch_one(&h.db)
248 .await
249 .unwrap();
250 assert_eq!(split_count, 1, "idempotent: still exactly one split");
251 }
252
253 /// Deliver a `checkout.session.completed` webhook for a tip on `session_id`.
254 async fn deliver_tip_webhook(
255 h: &mut TestHarness,
256 event_id: &str,
257 session_id: &str,
258 tipper_id: db::UserId,
259 recipient_id: db::UserId,
260 ) -> u16 {
261 let mut meta = HashMap::new();
262 meta.insert("checkout_type".to_string(), "tip".to_string());
263 meta.insert("tipper_id".to_string(), tipper_id.to_string());
264 meta.insert("recipient_id".to_string(), recipient_id.to_string());
265 let session = serde_json::json!({
266 "id": session_id,
267 "object": "checkout_session",
268 "mode": "payment",
269 "metadata": meta,
270 "payment_intent": "pi_tip_crash_recovery",
271 });
272 let payload = serde_json::json!({
273 "id": event_id,
274 "type": "checkout.session.completed",
275 "data": {"object": session},
276 })
277 .to_string();
278 let signature = crate::harness::stripe::sign_webhook_payload(
279 &payload,
280 crate::harness::stripe::TEST_WEBHOOK_SECRET,
281 );
282 h.client
283 .request_with_headers(
284 "POST",
285 "/stripe/webhook",
286 Some(&payload),
287 &[
288 ("stripe-signature", &signature),
289 ("content-type", "application/json"),
290 ],
291 )
292 .await
293 .status
294 .as_u16()
295 }
296
297 /// Tip revenue splits must survive a crash between `complete_tip` and the split
298 /// write, and a redelivery must not double-credit collaborators (Run 20 chronic
299 /// money-loss). Mirrors the purchase recovery test for the tip path.
300 #[tokio::test]
301 async fn tip_split_crash_recovery_is_idempotent() {
302 let mut h = TestHarness::with_mocks().await;
303
304 // Recipient (creator) with a project and a 50% collaborator.
305 let recipient_id = h.signup("trecip", "trecip@test.com", "pass1234").await;
306 let project_id: db::ProjectId = sqlx::query_scalar(
307 "INSERT INTO projects (user_id, slug, title) VALUES ($1, 'tshop', 'TShop') RETURNING id",
308 )
309 .bind(recipient_id)
310 .fetch_one(&h.db)
311 .await
312 .unwrap();
313 let collab_id = h.signup("tcollab", "tcollab@test.com", "pass1234").await;
314 sqlx::query(
315 "INSERT INTO project_members (project_id, user_id, role, split_percent, added_by) VALUES ($1, $2, 'member', 50, $3)",
316 )
317 .bind(project_id)
318 .bind(collab_id)
319 .bind(recipient_id)
320 .execute(&h.db)
321 .await
322 .unwrap();
323
324 // A tipper and a pending tip attributed to the project.
325 let tipper_id = h.signup("ttipper", "ttipper@test.com", "pass1234").await;
326 let session_id = "cs_tip_crash_001";
327 db::tips::create_tip(
328 &h.db,
329 tipper_id,
330 recipient_id,
331 Some(project_id),
332 1000,
333 None,
334 session_id,
335 )
336 .await
337 .unwrap();
338 let tip_id: db::TipId =
339 sqlx::query_scalar("SELECT id FROM tips WHERE stripe_checkout_session_id = $1")
340 .bind(session_id)
341 .fetch_one(&h.db)
342 .await
343 .unwrap();
344
345 // First delivery completes the tip and records the split.
346 let status =
347 deliver_tip_webhook(&mut h, "evt_tip_1", session_id, tipper_id, recipient_id).await;
348 assert_eq!(status, 200, "first tip webhook should succeed");
349 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
350 let split_count: i64 =
351 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1")
352 .bind(tip_id)
353 .fetch_one(&h.db)
354 .await
355 .unwrap();
356 assert_eq!(
357 split_count, 1,
358 "first delivery records the collaborator split"
359 );
360
361 // Simulate a crash before the split write: tip stays completed, split gone.
362 sqlx::query("DELETE FROM revenue_splits WHERE tip_id = $1")
363 .bind(tip_id)
364 .execute(&h.db)
365 .await
366 .unwrap();
367
368 // Redelivery (new event id): complete_tip returns None; the recovery branch
369 // re-fetches the tip and re-runs the idempotent split write.
370 let status =
371 deliver_tip_webhook(&mut h, "evt_tip_2", session_id, tipper_id, recipient_id).await;
372 assert_eq!(status, 200, "recovery tip webhook should succeed");
373 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
374 let split_count: i64 =
375 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1")
376 .bind(tip_id)
377 .fetch_one(&h.db)
378 .await
379 .unwrap();
380 assert_eq!(split_count, 1, "recovery re-records the missing tip split");
381
382 // A second recovery must NOT duplicate (ON CONFLICT DO NOTHING, migration 163).
383 let status =
384 deliver_tip_webhook(&mut h, "evt_tip_3", session_id, tipper_id, recipient_id).await;
385 assert_eq!(status, 200, "second recovery tip webhook should succeed");
386 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
387 let split_count: i64 =
388 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1")
389 .bind(tip_id)
390 .fetch_one(&h.db)
391 .await
392 .unwrap();
393 assert_eq!(split_count, 1, "idempotent: still exactly one tip split");
394 }
395
396 /// The partial unique index on license_keys (transaction_id) structurally rejects
397 /// a second auto-minted key for the same transaction even if the pre-check is
398 /// bypassed.
399 #[tokio::test]
400 async fn license_keys_transaction_id_unique_index_rejects_double_mint() {
401 let h = TestHarness::new().await;
402
403 // Minimal fixtures: a user, project, item, and completed transaction.
404 let owner_id: db::UserId = sqlx::query_scalar(
405 "INSERT INTO users (username, email, password_hash, email_verified) \
406 VALUES ('idxowner', 'idxowner@test.com', 'x', true) RETURNING id",
407 )
408 .fetch_one(&h.db)
409 .await
410 .unwrap();
411 let project_id: db::ProjectId = sqlx::query_scalar(
412 "INSERT INTO projects (user_id, slug, title) VALUES ($1, 'idxproj', 'Idx') RETURNING id",
413 )
414 .bind(owner_id)
415 .fetch_one(&h.db)
416 .await
417 .unwrap();
418 let item_id: db::ItemId = sqlx::query_scalar(
419 "INSERT INTO items (project_id, title, item_type, price_cents, slug) VALUES ($1, 'Idx Item', 'plugin', 0, 'idx-item') RETURNING id",
420 ).bind(project_id).fetch_one(&h.db).await.unwrap();
421 let tx_id: db::TransactionId = sqlx::query_scalar(
422 "INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status) \
423 VALUES ($1, $1, $2, 0, 'completed') RETURNING id",
424 )
425 .bind(owner_id)
426 .bind(item_id)
427 .fetch_one(&h.db)
428 .await
429 .unwrap();
430
431 // First key for the transaction: OK.
432 let first = sqlx::query(
433 "INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code) VALUES ($1, $2, $3, 'aaa-bbb-ccc')",
434 ).bind(item_id).bind(owner_id).bind(tx_id).execute(&h.db).await;
435 assert!(first.is_ok(), "first key insert should succeed: {first:?}");
436
437 // Second key for the SAME transaction: rejected by the partial unique index.
438 let second = sqlx::query(
439 "INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code) VALUES ($1, $2, $3, 'ddd-eee-fff')",
440 ).bind(item_id).bind(owner_id).bind(tx_id).execute(&h.db).await;
441 assert!(
442 second.is_err(),
443 "second key for same transaction must be rejected by unique index"
444 );
445
446 // A manually-created key (transaction_id NULL) is unconstrained: many allowed.
447 for code in ["man-1", "man-2"] {
448 let manual = sqlx::query(
449 "INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code) VALUES ($1, $2, NULL, $3)",
450 ).bind(item_id).bind(owner_id).bind(code).execute(&h.db).await;
451 assert!(
452 manual.is_ok(),
453 "manual key {code} should be allowed (partial index excludes NULL)"
454 );
455 }
456 }
457