Skip to main content

max / makenotwork

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