Skip to main content

max / makenotwork

26.3 KB · 790 lines History Blame Raw
1 //! Adversarial business-logic tests.
2 //!
3 //! Focus: Checkout, purchase, library, promo code, and PWYW boundary abuse.
4 //! Each test attempts to exploit a business-logic flaw. Tests that PASS prove
5 //! the app correctly rejects the exploit.
6
7 use crate::harness::TestHarness;
8 use makenotwork::db;
9 use serde_json::Value;
10
11 /// Helper: create a creator with a published paid item ($10) and a published free item.
12 /// Returns (creator_id, project_id, paid_item_id, free_item_id).
13 /// Stays logged in as the creator.
14 async fn setup_creator_with_items(h: &mut TestHarness) -> (db::UserId, String, String, String) {
15 let setup = h.create_creator_with_item("bizseller", "digital", 1000).await;
16 let paid_item_id = setup.item_id;
17
18 // Create second (free) item in same project
19 let resp = h
20 .client
21 .post_form(
22 &format!("/api/projects/{}/items", setup.project_id),
23 "title=Free+Item&item_type=digital&price_cents=0",
24 )
25 .await;
26 assert!(resp.status.is_success(), "Create free item failed: {}", resp.text);
27 let free: Value = resp.json();
28 let free_item_id = free["id"].as_str().unwrap().to_string();
29
30 // Publish all
31 h.publish_project_and_item(&setup.project_id, &paid_item_id).await;
32 h.client
33 .put_form(&format!("/api/items/{}", free_item_id), "is_public=true")
34 .await;
35
36 (setup.user_id, setup.project_id, paid_item_id, free_item_id)
37 }
38
39 // =============================================================================
40 // Self-purchase prevention
41 // =============================================================================
42
43 /// Vulnerability tested: Creator buys their own item to inflate sales/launder funds.
44 #[tokio::test]
45 async fn self_purchase_blocked() {
46 let mut h = TestHarness::new().await;
47 let (_creator_id, _project_id, paid_item_id, _free_item_id) =
48 setup_creator_with_items(&mut h).await;
49
50 // Creator tries to checkout their own paid item
51 let resp = h
52 .client
53 .post_form(
54 &format!("/stripe/checkout/{}", paid_item_id),
55 "",
56 )
57 .await;
58 assert_eq!(
59 resp.status, 400,
60 "Creator should not be able to purchase their own item: {} {}",
61 resp.status, resp.text
62 );
63 }
64
65 /// Vulnerability tested: Creator adds their own free item to library to inflate sales count.
66 #[tokio::test]
67 async fn self_claim_free_item_allowed_but_idempotent() {
68 let mut h = TestHarness::new().await;
69 let (_creator_id, _project_id, _paid_item_id, free_item_id) =
70 setup_creator_with_items(&mut h).await;
71
72 // Creator adds their own free item — this is allowed (they own it anyway)
73 let resp = h
74 .client
75 .post_form(&format!("/api/library/add/{}", free_item_id), "")
76 .await;
77 assert!(
78 resp.status.is_success(),
79 "Adding own free item to library should work: {} {}",
80 resp.status, resp.text
81 );
82
83 // Adding again should be idempotent (no error, not double-counted)
84 let resp = h
85 .client
86 .post_form(&format!("/api/library/add/{}", free_item_id), "")
87 .await;
88 assert!(
89 resp.status.is_success(),
90 "Duplicate add should not error: {} {}",
91 resp.status, resp.text
92 );
93 }
94
95 // =============================================================================
96 // Draft/unpublished item abuse
97 // =============================================================================
98
99 /// Vulnerability tested: Buyer checks out a draft item that shouldn't be purchasable.
100 #[tokio::test]
101 async fn draft_item_checkout_rejected() {
102 let mut h = TestHarness::new().await;
103 let creator_id = h.signup("draftseller", "draftseller@test.com", "password123").await;
104 h.grant_creator(creator_id).await;
105 h.client.post_form("/logout", "").await;
106 h.login("draftseller", "password123").await;
107
108 let resp = h
109 .client
110 .post_form("/api/projects", "slug=draft-shop&title=Draft+Shop")
111 .await;
112 let project: Value = resp.json();
113 let project_id = project["id"].as_str().unwrap();
114
115 // Create item but DO NOT publish it
116 let resp = h
117 .client
118 .post_form(
119 &format!("/api/projects/{}/items", project_id),
120 "title=Draft+Item&item_type=digital&price_cents=500",
121 )
122 .await;
123 let item: Value = resp.json();
124 let item_id = item["id"].as_str().unwrap();
125
126 // Publish the project but NOT the item
127 h.client
128 .put_json(
129 &format!("/api/projects/{}", project_id),
130 r#"{"is_public": true}"#,
131 )
132 .await;
133
134 // Switch to buyer
135 h.client.post_form("/logout", "").await;
136 let _buyer_id = h.signup("draftbuyer", "draftbuyer@test.com", "password456").await;
137
138 // Buyer tries to checkout the draft item
139 let resp = h
140 .client
141 .post_form(&format!("/stripe/checkout/{}", item_id), "")
142 .await;
143 assert!(
144 resp.status.is_client_error(),
145 "Draft item checkout should be rejected: {} {}",
146 resp.status, resp.text
147 );
148 }
149
150 /// Vulnerability tested: Buyer claims a draft free item via library-add.
151 #[tokio::test]
152 async fn draft_free_item_library_add_rejected() {
153 let mut h = TestHarness::new().await;
154 let creator_id = h.signup("draftfree", "draftfree@test.com", "password123").await;
155 h.grant_creator(creator_id).await;
156 h.client.post_form("/logout", "").await;
157 h.login("draftfree", "password123").await;
158
159 let resp = h
160 .client
161 .post_form("/api/projects", "slug=draftfree-shop&title=DraftFree")
162 .await;
163 let project: Value = resp.json();
164 let project_id = project["id"].as_str().unwrap();
165
166 // Create free item, then explicitly unpublish it (items default to public)
167 let resp = h
168 .client
169 .post_form(
170 &format!("/api/projects/{}/items", project_id),
171 "title=Hidden+Free&item_type=digital&price_cents=0",
172 )
173 .await;
174 let item: Value = resp.json();
175 let item_id = item["id"].as_str().unwrap();
176 h.client
177 .put_form(&format!("/api/items/{}", item_id), "is_public=false")
178 .await;
179
180 // Switch to buyer
181 h.client.post_form("/logout", "").await;
182 let _buyer_id = h.signup("draftfreebuyer", "draftfreebuyer@test.com", "password456").await;
183
184 // Try to claim the draft free item
185 let resp = h
186 .client
187 .post_form(&format!("/api/library/add/{}", item_id), "")
188 .await;
189 assert!(
190 resp.status.is_client_error(),
191 "Draft free item should not be claimable: {} {}",
192 resp.status, resp.text
193 );
194 }
195
196 // =============================================================================
197 // Free vs paid boundary
198 // =============================================================================
199
200 /// Vulnerability tested: Buyer uses checkout endpoint for a free (non-PWYW) item,
201 /// trying to bypass the library-add flow.
202 #[tokio::test]
203 async fn free_item_checkout_rejected() {
204 let mut h = TestHarness::new().await;
205 let (_creator_id, _project_id, _paid_item_id, free_item_id) =
206 setup_creator_with_items(&mut h).await;
207
208 // Switch to buyer
209 h.client.post_form("/logout", "").await;
210 let _buyer_id = h.signup("freechk", "freechk@test.com", "password456").await;
211
212 // Try to checkout a free item
213 let resp = h
214 .client
215 .post_form(&format!("/stripe/checkout/{}", free_item_id), "")
216 .await;
217 assert_eq!(
218 resp.status, 400,
219 "Free item checkout should be rejected: {} {}",
220 resp.status, resp.text
221 );
222 }
223
224 /// Vulnerability tested: Buyer uses library-add for a paid item, trying to get it free.
225 #[tokio::test]
226 async fn paid_item_library_add_rejected() {
227 let mut h = TestHarness::new().await;
228 let (_creator_id, _project_id, paid_item_id, _free_item_id) =
229 setup_creator_with_items(&mut h).await;
230
231 // Switch to buyer
232 h.client.post_form("/logout", "").await;
233 let _buyer_id = h.signup("paidlib", "paidlib@test.com", "password456").await;
234
235 // Try to add paid item to library (free-claim endpoint)
236 let resp = h
237 .client
238 .post_form(&format!("/api/library/add/{}", paid_item_id), "")
239 .await;
240 assert!(
241 resp.status.is_client_error(),
242 "Paid item should not be claimable via library-add: {} {}",
243 resp.status, resp.text
244 );
245 }
246
247 // =============================================================================
248 // Double-purchase prevention
249 // =============================================================================
250
251 /// Vulnerability tested: Buyer tries to purchase the same item twice.
252 /// Uses a 100% discount code to complete a free-claim first purchase.
253 #[tokio::test]
254 async fn double_purchase_redirects() {
255 let mut h = TestHarness::new().await;
256 let (_creator_id, _project_id, paid_item_id, _free_item_id) =
257 setup_creator_with_items(&mut h).await;
258
259 // Create 100% discount code
260 let resp = h
261 .client
262 .post_form(
263 "/api/promo-codes",
264 "code=FREE100&code_purpose=discount&discount_type=percentage&discount_value=100",
265 )
266 .await;
267 assert!(resp.status.is_success(), "Create promo code failed: {}", resp.text);
268
269 // Switch to buyer
270 h.client.post_form("/logout", "").await;
271 let _buyer_id = h.signup("doublebuyer", "doublebuyer@test.com", "password456").await;
272
273 // First purchase with 100% discount → free claim path
274 let resp = h
275 .client
276 .post_form(
277 &format!("/stripe/checkout/{}", paid_item_id),
278 "promo_code=FREE100",
279 )
280 .await;
281 assert!(
282 resp.status.is_redirection() || resp.status.is_success(),
283 "First purchase should succeed: {} {}",
284 resp.status, resp.text
285 );
286
287 // Second purchase attempt → should redirect to item page (already owned)
288 let resp = h
289 .client
290 .post_form(
291 &format!("/stripe/checkout/{}", paid_item_id),
292 "",
293 )
294 .await;
295 assert!(
296 resp.status.is_redirection(),
297 "Double purchase should redirect: {} {}",
298 resp.status, resp.text
299 );
300 }
301
302 // =============================================================================
303 // Promo code cross-creator abuse
304 // =============================================================================
305
306 /// Vulnerability tested: Buyer uses seller A's discount code on seller B's item.
307 /// The code lookup is scoped by seller_id, so it should be "Invalid".
308 #[tokio::test]
309 async fn promo_code_cross_creator_rejected() {
310 let mut h = TestHarness::new().await;
311
312 // Seller A creates a 100% discount code
313 let seller_a = h.signup("sellera", "sellera@test.com", "password123").await;
314 h.grant_creator(seller_a).await;
315 h.client.post_form("/logout", "").await;
316 h.login("sellera", "password123").await;
317
318 let resp = h
319 .client
320 .post_form(
321 "/api/promo-codes",
322 "code=STEALME&code_purpose=discount&discount_type=percentage&discount_value=100",
323 )
324 .await;
325 assert!(resp.status.is_success(), "Create promo code failed: {}", resp.text);
326
327 // Seller B creates a published paid item
328 h.client.post_form("/logout", "").await;
329 let seller_b = h.signup("sellerb", "sellerb@test.com", "password123").await;
330 h.grant_creator(seller_b).await;
331 h.client.post_form("/logout", "").await;
332 h.login("sellerb", "password123").await;
333
334 let resp = h
335 .client
336 .post_form("/api/projects", "slug=b-shop&title=B+Shop")
337 .await;
338 let project: Value = resp.json();
339 let project_id = project["id"].as_str().unwrap();
340
341 let resp = h
342 .client
343 .post_form(
344 &format!("/api/projects/{}/items", project_id),
345 "title=B+Item&item_type=digital&price_cents=2000",
346 )
347 .await;
348 let item: Value = resp.json();
349 let item_id = item["id"].as_str().unwrap();
350
351 h.client
352 .put_json(
353 &format!("/api/projects/{}", project_id),
354 r#"{"is_public": true}"#,
355 )
356 .await;
357 h.client
358 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
359 .await;
360
361 // Buyer tries seller A's code on seller B's item
362 h.client.post_form("/logout", "").await;
363 let _buyer_id = h.signup("crossbuyer", "crossbuyer@test.com", "password456").await;
364
365 let resp = h
366 .client
367 .post_form(
368 &format!("/stripe/checkout/{}", item_id),
369 "promo_code=STEALME",
370 )
371 .await;
372 assert_eq!(
373 resp.status, 400,
374 "Cross-creator promo code should be rejected: {} {}",
375 resp.status, resp.text
376 );
377 }
378
379 // =============================================================================
380 // Promo code scope abuse
381 // =============================================================================
382
383 /// Vulnerability tested: Promo code scoped to item A used on item B (same creator).
384 #[tokio::test]
385 async fn promo_code_wrong_item_scope_rejected() {
386 let mut h = TestHarness::new().await;
387 let (_creator_id, project_id, paid_item_id, _free_item_id) =
388 setup_creator_with_items(&mut h).await;
389
390 // Create a second paid item
391 let resp = h
392 .client
393 .post_form(
394 &format!("/api/projects/{}/items", project_id),
395 "title=Other+Item&item_type=digital&price_cents=500",
396 )
397 .await;
398 assert!(resp.status.is_success());
399 let other: Value = resp.json();
400 let other_item_id = other["id"].as_str().unwrap();
401 h.client
402 .put_form(&format!("/api/items/{}", other_item_id), "is_public=true")
403 .await;
404
405 // Create 100% discount code scoped to the FIRST item
406 let resp = h
407 .client
408 .post_form(
409 "/api/promo-codes",
410 &format!(
411 "code=ITEM1ONLY&code_purpose=discount&discount_type=percentage&discount_value=100&item_id={}",
412 paid_item_id
413 ),
414 )
415 .await;
416 assert!(resp.status.is_success(), "Create scoped code failed: {}", resp.text);
417
418 // Buyer uses code on the SECOND item
419 h.client.post_form("/logout", "").await;
420 let _buyer_id = h.signup("scopebuyer", "scopebuyer@test.com", "password456").await;
421
422 let resp = h
423 .client
424 .post_form(
425 &format!("/stripe/checkout/{}", other_item_id),
426 "promo_code=ITEM1ONLY",
427 )
428 .await;
429 assert_eq!(
430 resp.status, 400,
431 "Item-scoped code on wrong item should be rejected: {} {}",
432 resp.status, resp.text
433 );
434 }
435
436 /// Vulnerability tested: Promo code scoped to project A used on item from project B.
437 #[tokio::test]
438 async fn promo_code_wrong_project_scope_rejected() {
439 let mut h = TestHarness::new().await;
440
441 // Creator with two projects
442 let creator_id = h.signup("projscope", "projscope@test.com", "password123").await;
443 h.grant_creator(creator_id).await;
444 h.client.post_form("/logout", "").await;
445 h.login("projscope", "password123").await;
446
447 // Project 1
448 let resp = h
449 .client
450 .post_form("/api/projects", "slug=proj1-shop&title=Proj1")
451 .await;
452 assert!(resp.status.is_success());
453 let p1: Value = resp.json();
454 let project1_id = p1["id"].as_str().unwrap().to_string();
455
456 // Project 2 with a paid item
457 let resp = h
458 .client
459 .post_form("/api/projects", "slug=proj2-shop&title=Proj2")
460 .await;
461 assert!(resp.status.is_success());
462 let p2: Value = resp.json();
463 let project2_id = p2["id"].as_str().unwrap();
464
465 let resp = h
466 .client
467 .post_form(
468 &format!("/api/projects/{}/items", project2_id),
469 "title=P2+Item&item_type=digital&price_cents=800",
470 )
471 .await;
472 assert!(resp.status.is_success());
473 let item2: Value = resp.json();
474 let item2_id = item2["id"].as_str().unwrap();
475
476 h.client
477 .put_json(
478 &format!("/api/projects/{}", project2_id),
479 r#"{"is_public": true}"#,
480 )
481 .await;
482
483 // Create 100% discount code scoped to project 1
484 let resp = h
485 .client
486 .post_form(
487 "/api/promo-codes",
488 &format!(
489 "code=PROJ1ONLY&code_purpose=discount&discount_type=percentage&discount_value=100&project_id={}",
490 project1_id
491 ),
492 )
493 .await;
494 assert!(resp.status.is_success(), "Create project-scoped code failed: {}", resp.text);
495
496 // Buyer uses code on item from project 2
497 h.client.post_form("/logout", "").await;
498 let _buyer_id = h.signup("projbuyer", "projbuyer@test.com", "password456").await;
499
500 let resp = h
501 .client
502 .post_form(
503 &format!("/stripe/checkout/{}", item2_id),
504 "promo_code=PROJ1ONLY",
505 )
506 .await;
507 assert_eq!(
508 resp.status, 400,
509 "Project-scoped code on wrong project should be rejected: {} {}",
510 resp.status, resp.text
511 );
512 }
513
514 // =============================================================================
515 // Promo code exhaustion
516 // =============================================================================
517
518 /// Vulnerability tested: Exhausted promo code (max_uses reached) still accepted.
519 /// Uses the claim endpoint with a free_access code (max_uses=1).
520 #[tokio::test]
521 async fn exhausted_promo_code_rejected() {
522 let mut h = TestHarness::new().await;
523 let creator_id = h.signup("exhseller", "exhseller@test.com", "password123").await;
524 h.grant_creator(creator_id).await;
525 h.client.post_form("/logout", "").await;
526 h.login("exhseller", "password123").await;
527
528 let resp = h
529 .client
530 .post_form("/api/projects", "slug=exh-shop&title=Exh+Shop")
531 .await;
532 let project: Value = resp.json();
533 let project_id = project["id"].as_str().unwrap();
534
535 let resp = h
536 .client
537 .post_form(
538 &format!("/api/projects/{}/items", project_id),
539 "title=Exh+Item&item_type=digital&price_cents=0",
540 )
541 .await;
542 let item: Value = resp.json();
543 let item_id = item["id"].as_str().unwrap();
544
545 h.client
546 .put_json(
547 &format!("/api/projects/{}", project_id),
548 r#"{"is_public": true}"#,
549 )
550 .await;
551 h.client
552 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
553 .await;
554
555 // Create free_access code with max_uses=1
556 let resp = h
557 .client
558 .post_form(
559 "/api/promo-codes",
560 &format!("code_purpose=free_access&item_id={}&max_uses=1", item_id),
561 )
562 .await;
563 assert!(resp.status.is_success(), "Create code failed: {}", resp.text);
564 let code: Value = resp.json();
565 let key_code = code["code"].as_str().unwrap().to_string();
566
567 // Buyer 1 claims successfully
568 h.client.post_form("/logout", "").await;
569 let _buyer1 = h.signup("exhbuyer1", "exhbuyer1@test.com", "password456").await;
570
571 let resp = h
572 .client
573 .post_form("/api/promo-codes/claim", &format!("code={}", key_code))
574 .await;
575 assert!(
576 resp.status.is_success(),
577 "First claim should succeed: {} {}",
578 resp.status, resp.text
579 );
580
581 // Buyer 2 tries to claim — should be rejected (max_uses exhausted)
582 h.client.post_form("/logout", "").await;
583 let _buyer2 = h.signup("exhbuyer2", "exhbuyer2@test.com", "password456").await;
584
585 let resp = h
586 .client
587 .post_form("/api/promo-codes/claim", &format!("code={}", key_code))
588 .await;
589 assert_eq!(
590 resp.status, 400,
591 "Exhausted code should be rejected: {} {}",
592 resp.status, resp.text
593 );
594 assert!(
595 resp.text.contains("usage limit"),
596 "Error should mention usage limit: {}",
597 resp.text
598 );
599 }
600
601 // =============================================================================
602 // PWYW abuse
603 // =============================================================================
604
605 /// Vulnerability tested: PWYW amount below minimum.
606 #[tokio::test]
607 async fn pwyw_below_minimum_rejected() {
608 let mut h = TestHarness::new().await;
609 let creator_id = h.signup("pwyws", "pwyws@test.com", "password123").await;
610 h.grant_creator(creator_id).await;
611 h.client.post_form("/logout", "").await;
612 h.login("pwyws", "password123").await;
613
614 let resp = h
615 .client
616 .post_form("/api/projects", "slug=pwyw-shop&title=PWYW+Shop")
617 .await;
618 let project: Value = resp.json();
619 let project_id = project["id"].as_str().unwrap();
620
621 // Create PWYW item with min $5
622 let resp = h
623 .client
624 .post_form(
625 &format!("/api/projects/{}/items", project_id),
626 "title=PWYW+Item&item_type=digital&price_cents=1000&pwyw_enabled=true&pwyw_min_cents=500",
627 )
628 .await;
629 assert!(resp.status.is_success(), "Create PWYW item failed: {}", resp.text);
630 let item: Value = resp.json();
631 let item_id = item["id"].as_str().unwrap();
632
633 h.client
634 .put_json(
635 &format!("/api/projects/{}", project_id),
636 r#"{"is_public": true}"#,
637 )
638 .await;
639 h.client
640 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
641 .await;
642
643 // Switch to buyer
644 h.client.post_form("/logout", "").await;
645 let _buyer_id = h.signup("pwywbuyer", "pwywbuyer@test.com", "password456").await;
646
647 // Try to pay $1 (below $5 minimum)
648 let resp = h
649 .client
650 .post_form(
651 &format!("/stripe/checkout/{}", item_id),
652 "amount_cents=100",
653 )
654 .await;
655 assert_eq!(
656 resp.status, 400,
657 "PWYW below minimum should be rejected: {} {}",
658 resp.status, resp.text
659 );
660 }
661
662 /// Vulnerability tested: PWYW item submitted without amount_cents.
663 #[tokio::test]
664 async fn pwyw_missing_amount_rejected() {
665 let mut h = TestHarness::new().await;
666 let creator_id = h.signup("pwywm", "pwywm@test.com", "password123").await;
667 h.grant_creator(creator_id).await;
668 h.client.post_form("/logout", "").await;
669 h.login("pwywm", "password123").await;
670
671 let resp = h
672 .client
673 .post_form("/api/projects", "slug=pwywm-shop&title=PWYWM+Shop")
674 .await;
675 let project: Value = resp.json();
676 let project_id = project["id"].as_str().unwrap();
677
678 let resp = h
679 .client
680 .post_form(
681 &format!("/api/projects/{}/items", project_id),
682 "title=PWYW+Item2&item_type=digital&price_cents=1000&pwyw_enabled=true&pwyw_min_cents=500",
683 )
684 .await;
685 assert!(resp.status.is_success(), "Create PWYW item failed: {}", resp.text);
686 let item: Value = resp.json();
687 let item_id = item["id"].as_str().unwrap();
688
689 h.client
690 .put_json(
691 &format!("/api/projects/{}", project_id),
692 r#"{"is_public": true}"#,
693 )
694 .await;
695 h.client
696 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
697 .await;
698
699 // Switch to buyer
700 h.client.post_form("/logout", "").await;
701 let _buyer_id = h.signup("pwywmbuyer", "pwywmbuyer@test.com", "password456").await;
702
703 // Submit checkout without amount_cents
704 let resp = h
705 .client
706 .post_form(
707 &format!("/stripe/checkout/{}", item_id),
708 "",
709 )
710 .await;
711 assert_eq!(
712 resp.status, 400,
713 "PWYW without amount should be rejected: {} {}",
714 resp.status, resp.text
715 );
716 }
717
718 // =============================================================================
719 // Discount applies to list price, not PWYW amount
720 // =============================================================================
721
722 /// Verification: Discount code applies to the item's list price, not the buyer's
723 /// chosen PWYW amount. A 100% discount on a PWYW item should make it free
724 /// (the buyer can't inflate the "discounted" amount by choosing a high PWYW price).
725 #[tokio::test]
726 async fn discount_applies_to_list_price_not_pwyw() {
727 let mut h = TestHarness::new().await;
728 let creator_id = h.signup("pwywd", "pwywd@test.com", "password123").await;
729 h.grant_creator(creator_id).await;
730 h.client.post_form("/logout", "").await;
731 h.login("pwywd", "password123").await;
732
733 let resp = h
734 .client
735 .post_form("/api/projects", "slug=pwywd-shop&title=PWYWD+Shop")
736 .await;
737 let project: Value = resp.json();
738 let project_id = project["id"].as_str().unwrap();
739
740 // PWYW item, list price $10, min $0
741 let resp = h
742 .client
743 .post_form(
744 &format!("/api/projects/{}/items", project_id),
745 "title=PWYW+Disc&item_type=digital&price_cents=1000&pwyw_enabled=true&pwyw_min_cents=0",
746 )
747 .await;
748 assert!(resp.status.is_success());
749 let item: Value = resp.json();
750 let item_id = item["id"].as_str().unwrap();
751
752 h.client
753 .put_json(
754 &format!("/api/projects/{}", project_id),
755 r#"{"is_public": true}"#,
756 )
757 .await;
758 h.client
759 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
760 .await;
761
762 // 100% discount code
763 let resp = h
764 .client
765 .post_form(
766 "/api/promo-codes",
767 "code=FULL100&code_purpose=discount&discount_type=percentage&discount_value=100",
768 )
769 .await;
770 assert!(resp.status.is_success());
771
772 // Buyer chooses $50 PWYW, but 100% discount on $10 list price → $0 → free claim
773 h.client.post_form("/logout", "").await;
774 let _buyer_id = h.signup("pwywdbuyer", "pwywdbuyer@test.com", "password456").await;
775
776 let resp = h
777 .client
778 .post_form(
779 &format!("/stripe/checkout/{}", item_id),
780 "amount_cents=5000&promo_code=FULL100",
781 )
782 .await;
783 // Should succeed via free-claim path (redirect to /library)
784 assert!(
785 resp.status.is_redirection() || resp.status.is_success(),
786 "100% discount should trigger free claim: {} {}",
787 resp.status, resp.text
788 );
789 }
790