Skip to main content

max / makenotwork

16.2 KB · 458 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, accepted_at) VALUES ($1::uuid, $2, 'member', 50, $3, NOW())",
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, accepted_at) VALUES ($1, $2, 'member', 50, $3, NOW())",
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 makenotwork::currency::SettlementCurrency::Usd,
336 )
337 .await
338 .unwrap();
339 let tip_id: db::TipId =
340 sqlx::query_scalar("SELECT id FROM tips WHERE stripe_checkout_session_id = $1")
341 .bind(session_id)
342 .fetch_one(&h.db)
343 .await
344 .unwrap();
345
346 // First delivery completes the tip and records the split.
347 let status =
348 deliver_tip_webhook(&mut h, "evt_tip_1", session_id, tipper_id, recipient_id).await;
349 assert_eq!(status, 200, "first tip webhook should succeed");
350 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
351 let split_count: i64 =
352 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1")
353 .bind(tip_id)
354 .fetch_one(&h.db)
355 .await
356 .unwrap();
357 assert_eq!(
358 split_count, 1,
359 "first delivery records the collaborator split"
360 );
361
362 // Simulate a crash before the split write: tip stays completed, split gone.
363 sqlx::query("DELETE FROM revenue_splits WHERE tip_id = $1")
364 .bind(tip_id)
365 .execute(&h.db)
366 .await
367 .unwrap();
368
369 // Redelivery (new event id): complete_tip returns None; the recovery branch
370 // re-fetches the tip and re-runs the idempotent split write.
371 let status =
372 deliver_tip_webhook(&mut h, "evt_tip_2", session_id, tipper_id, recipient_id).await;
373 assert_eq!(status, 200, "recovery tip webhook should succeed");
374 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
375 let split_count: i64 =
376 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1")
377 .bind(tip_id)
378 .fetch_one(&h.db)
379 .await
380 .unwrap();
381 assert_eq!(split_count, 1, "recovery re-records the missing tip split");
382
383 // A second recovery must NOT duplicate (ON CONFLICT DO NOTHING, migration 163).
384 let status =
385 deliver_tip_webhook(&mut h, "evt_tip_3", session_id, tipper_id, recipient_id).await;
386 assert_eq!(status, 200, "second recovery tip webhook should succeed");
387 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
388 let split_count: i64 =
389 sqlx::query_scalar("SELECT COUNT(*) FROM revenue_splits WHERE tip_id = $1")
390 .bind(tip_id)
391 .fetch_one(&h.db)
392 .await
393 .unwrap();
394 assert_eq!(split_count, 1, "idempotent: still exactly one tip split");
395 }
396
397 /// The partial unique index on license_keys (transaction_id) structurally rejects
398 /// a second auto-minted key for the same transaction even if the pre-check is
399 /// bypassed.
400 #[tokio::test]
401 async fn license_keys_transaction_id_unique_index_rejects_double_mint() {
402 let h = TestHarness::new().await;
403
404 // Minimal fixtures: a user, project, item, and completed transaction.
405 let owner_id: db::UserId = sqlx::query_scalar(
406 "INSERT INTO users (username, email, password_hash, email_verified) \
407 VALUES ('idxowner', 'idxowner@test.com', 'x', true) RETURNING id",
408 )
409 .fetch_one(&h.db)
410 .await
411 .unwrap();
412 let project_id: db::ProjectId = sqlx::query_scalar(
413 "INSERT INTO projects (user_id, slug, title) VALUES ($1, 'idxproj', 'Idx') RETURNING id",
414 )
415 .bind(owner_id)
416 .fetch_one(&h.db)
417 .await
418 .unwrap();
419 let item_id: db::ItemId = sqlx::query_scalar(
420 "INSERT INTO items (project_id, title, item_type, price_cents, slug) VALUES ($1, 'Idx Item', 'plugin', 0, 'idx-item') RETURNING id",
421 ).bind(project_id).fetch_one(&h.db).await.unwrap();
422 let tx_id: db::TransactionId = sqlx::query_scalar(
423 "INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status) \
424 VALUES ($1, $1, $2, 0, 'completed') RETURNING id",
425 )
426 .bind(owner_id)
427 .bind(item_id)
428 .fetch_one(&h.db)
429 .await
430 .unwrap();
431
432 // First key for the transaction: OK.
433 let first = sqlx::query(
434 "INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code) VALUES ($1, $2, $3, 'aaa-bbb-ccc')",
435 ).bind(item_id).bind(owner_id).bind(tx_id).execute(&h.db).await;
436 assert!(first.is_ok(), "first key insert should succeed: {first:?}");
437
438 // Second key for the SAME transaction: rejected by the partial unique index.
439 let second = sqlx::query(
440 "INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code) VALUES ($1, $2, $3, 'ddd-eee-fff')",
441 ).bind(item_id).bind(owner_id).bind(tx_id).execute(&h.db).await;
442 assert!(
443 second.is_err(),
444 "second key for same transaction must be rejected by unique index"
445 );
446
447 // A manually-created key (transaction_id NULL) is unconstrained: many allowed.
448 for code in ["man-1", "man-2"] {
449 let manual = sqlx::query(
450 "INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code) VALUES ($1, $2, NULL, $3)",
451 ).bind(item_id).bind(owner_id).bind(code).execute(&h.db).await;
452 assert!(
453 manual.is_ok(),
454 "manual key {code} should be allowed (partial index excludes NULL)"
455 );
456 }
457 }
458