Skip to main content

max / makenotwork

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