//! Adversarial input validation & edge case tests. //! //! Focus: Input validation & edge cases (Option B from adversarial.md). //! Tests boundary conditions, malformed input, and injection attempts. //! Tests that PASS prove the app correctly validates/rejects bad input. //! Tests that FAIL have found a real bug — flag clearly. //! //! Note: This app returns 422 (Unprocessable Entity) for validation errors, //! which is more precise than 400 (Bad Request). use crate::harness::TestHarness; use serde_json::Value; /// Helper: sign up a creator with a published project and item. /// Returns (project_id, item_id) with the creator logged in. async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String) { let setup = h.create_creator_with_item("inputtest", "digital", 1000).await; h.publish_project_and_item(&setup.project_id, &setup.item_id).await; (setup.project_id, setup.item_id) } // ============================================================================= // UUID path parameter handling // ============================================================================= /// Vulnerability tested: Invalid UUID in path causes server error. /// Non-UUID string should be rejected cleanly (4xx), not panic or 500. #[tokio::test] async fn invalid_uuid_in_path_returns_4xx() { let mut h = TestHarness::new().await; let (_project_id, _item_id) = setup_creator_with_item(&mut h).await; // Malformed UUIDs (URI-safe characters only) for bad_id in &["not-a-uuid", "12345", "0000-bad-format"] { let resp = h.client.get(&format!("/api/items/{}/versions", bad_id)).await; assert!( resp.status.is_client_error() || resp.status == 404 || resp.status == 405, "Invalid UUID '{}' should not cause 500, got: {}", bad_id, resp.status ); } // Nil UUID — valid format but no resource exists let resp = h .client .get("/api/items/00000000-0000-0000-0000-000000000000/versions") .await; assert!( !resp.status.is_server_error(), "Nil UUID should not cause 500, got: {}", resp.status ); } // ============================================================================= // Numeric boundary conditions // ============================================================================= /// Vulnerability tested: Negative price_cents bypasses validation. /// price_cents should be >= 0 and <= 1,000,000 ($10,000). #[tokio::test] async fn negative_price_cents_rejected() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Evil+Item&item_type=digital&price_cents=-100", ) .await; assert!( resp.status.is_client_error(), "Negative price_cents should be rejected: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Overflow price_cents bypasses cap. /// price_cents should be capped at MAX_PRICE_CENTS (1,000,000 = $10,000). #[tokio::test] async fn overflow_price_cents_rejected() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Expensive&item_type=digital&price_cents=2000000000", ) .await; assert!( resp.status.is_client_error(), "Price > $10,000 should be rejected: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Zero price_cents accepted for free items. /// This is expected valid behavior — free items should work. #[tokio::test] async fn zero_price_cents_accepted() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=Free+Item&item_type=digital&price_cents=0", ) .await; assert!( resp.status.is_success(), "Zero price (free item) should be accepted: {} {}", resp.status, resp.text ); } // ============================================================================= // String length boundaries // ============================================================================= /// Vulnerability tested: Oversized item title bypasses length check. /// Item title limit is 200 chars. #[tokio::test] async fn item_title_too_long_rejected() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let long_title = "A".repeat(201); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title={}&item_type=digital", long_title), ) .await; assert!( resp.status.is_client_error(), "201-char title should be rejected: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Oversized item description bypasses length check. /// Item description limit is 5000 chars. #[tokio::test] async fn item_description_too_long_rejected() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let long_desc = "B".repeat(5001); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title=Normal&item_type=digital&description={}", long_desc), ) .await; assert!( resp.status.is_client_error(), "5001-char description should be rejected: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Empty required title accepted. /// Item title is required (min 1 char). #[tokio::test] async fn empty_item_title_rejected() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), "title=&item_type=digital", ) .await; assert!( resp.status.is_client_error(), "Empty title should be rejected: {} {}", resp.status, resp.text ); } // ============================================================================= // Injection attempts // ============================================================================= /// Vulnerability tested: XSS payload in item title causes stored XSS. /// App should accept the text (it's valid content) but store it safely. /// Askama templates auto-escape by default, so the real defense is at render time. /// This test verifies the API round-trips the value without mangling it. #[tokio::test] async fn xss_in_title_stored_safely() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let xss_title = ""; let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title={}&item_type=digital", xss_title), ) .await; assert!( resp.status.is_success(), "XSS title should be accepted as content: {} {}", resp.status, resp.text ); let item: Value = resp.json(); assert_eq!( item["title"].as_str().unwrap(), xss_title, "Title should be stored verbatim (escaping happens at render time)" ); } /// Vulnerability tested: SQL injection in title causes data leak or mutation. /// All queries use parameterized statements (sqlx), so injection should be impossible. #[tokio::test] async fn sql_injection_in_title_harmless() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; let sqli_title = "'; DROP TABLE items; --"; let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title={}&item_type=digital", sqli_title), ) .await; assert!( resp.status.is_success(), "SQL injection payload should be stored as literal text: {} {}", resp.status, resp.text ); let item: Value = resp.json(); assert_eq!( item["title"].as_str().unwrap(), sqli_title, "SQL injection payload should be stored verbatim" ); // Verify the items table still works (not dropped) let resp = h.client.get("/api/projects").await; assert!(resp.status.is_success(), "Projects list should still work after SQL injection attempt"); } // ============================================================================= // Duplicate creation // ============================================================================= /// Vulnerability tested: Duplicate project slug creates confusion or overwrites. /// Second project with same slug should be rejected (unique constraint). #[tokio::test] async fn duplicate_project_slug_rejected() { let mut h = TestHarness::new().await; let (_project_id, _item_id) = setup_creator_with_item(&mut h).await; // Try creating another project with the same slug let resp = h .client .post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop") .await; assert!( !resp.status.is_success(), "Duplicate slug must not succeed: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Duplicate username on signup. /// Second signup with same username should be rejected. #[tokio::test] async fn duplicate_username_rejected() { let mut h = TestHarness::new().await; let _user_id = h.signup("dupuser", "dup1@test.com", "password123").await; h.client.post_form("/logout", "").await; // Try signing up again with the same username but different email let resp = h .client .post_form( "/join", "username=dupuser&email=dup2@test.com&password=password456&confirm_password=password456", ) .await; // Should fail — check we get an error, not a second account // The response could be a redirect back to join with error or a 400/409 let is_error = !resp.status.is_success() || resp.text.contains("taken") || resp.text.contains("already") || resp.text.contains("exists") || resp.status.is_redirection(); assert!( is_error, "Duplicate username should be rejected: {} {}", resp.status, resp.text ); } // ============================================================================= // Slug boundary values // ============================================================================= /// Vulnerability tested: Slug boundary values — min/max length enforcement. /// Slug requires 2-100 chars, alphanumeric + hyphen only. #[tokio::test] async fn slug_boundary_values() { let mut h = TestHarness::new().await; let user_id = h.signup("slugtest", "slugtest@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("slugtest", "password123").await; // 1-char slug — should be rejected (min 2) let resp = h .client .post_form("/api/projects", "slug=a&title=One+Char") .await; assert!( !resp.status.is_success(), "1-char slug should be rejected: {} {}", resp.status, resp.text ); // 2-char slug — should be accepted (boundary) let resp = h .client .post_form("/api/projects", "slug=ab&title=Two+Char") .await; assert!( resp.status.is_success(), "2-char slug should be accepted: {} {}", resp.status, resp.text ); // 100-char slug — should be accepted (boundary) let slug_100 = "a".repeat(100); let resp = h .client .post_form( "/api/projects", &format!("slug={}&title=Max+Slug", slug_100), ) .await; assert!( resp.status.is_success(), "100-char slug should be accepted: {} {}", resp.status, resp.text ); // 101-char slug — should be rejected (over max) let slug_101 = "b".repeat(101); let resp = h .client .post_form( "/api/projects", &format!("slug={}&title=Over+Max", slug_101), ) .await; assert!( !resp.status.is_success(), "101-char slug should be rejected: {} {}", resp.status, resp.text ); // Slug with special characters — should be rejected let resp = h .client .post_form("/api/projects", "slug=my_shop!&title=Special+Chars") .await; assert!( !resp.status.is_success(), "Slug with special chars should be rejected: {} {}", resp.status, resp.text ); } // ============================================================================= // Unicode handling // ============================================================================= /// Vulnerability tested: Multibyte characters bypass length limits. /// Length checks should count chars, not bytes. 200 CJK characters = 200 chars /// (within 200-char limit) but 600 bytes. #[tokio::test] async fn unicode_chars_counted_not_bytes() { let mut h = TestHarness::new().await; let (project_id, _item_id) = setup_creator_with_item(&mut h).await; // 200 CJK characters — should be accepted (within 200-char limit) let cjk_200: String = "\u{4e00}".repeat(200); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title={}&item_type=digital", cjk_200), ) .await; assert!( resp.status.is_success(), "200 CJK chars should be accepted (chars.count() not bytes): {} {}", resp.status, resp.text ); // 201 CJK characters — should be rejected let cjk_201: String = "\u{4e00}".repeat(201); let resp = h .client .post_form( &format!("/api/projects/{}/items", project_id), &format!("title={}&item_type=digital", cjk_201), ) .await; assert!( resp.status.is_client_error(), "201 CJK chars should be rejected: {} {}", resp.status, resp.text ); } // ============================================================================= // Validation boundaries — these gaps have been fixed // ============================================================================= /// PWYW minimum price rejects negative values. #[tokio::test] async fn pwyw_min_cents_negative_rejected() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h).await; let resp = h .client .put_form( &format!("/api/items/{}", item_id), "pwyw_enabled=on&pwyw_min_cents=-500", ) .await; assert!( resp.status.is_client_error(), "Negative pwyw_min_cents should be rejected: {} {}", resp.status, resp.text ); } /// Fixed discount value is capped at MAX_PRICE_CENTS. #[tokio::test] async fn fixed_discount_upper_bound_enforced() { let mut h = TestHarness::new().await; let (_project_id, _item_id) = setup_creator_with_item(&mut h).await; let resp = h .client .post_form( "/api/promo-codes", "code=BIGDISCOUNT&code_purpose=discount&discount_type=fixed&discount_value=99999999", ) .await; assert!( resp.status.is_client_error(), "Fixed discount above MAX_PRICE_CENTS should be rejected: {} {}", resp.status, resp.text ); } /// Vulnerability tested: Link URL with javascript: scheme should be rejected. /// Verifies that protocol validation prevents XSS via custom links. #[tokio::test] async fn link_javascript_scheme_rejected() { let mut h = TestHarness::new().await; let user_id = h.signup("linktest", "linktest@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("linktest", "password123").await; // javascript: scheme — classic XSS vector let resp = h .client .post_form( "/api/links", "title=Evil+Link&url=javascript:alert(document.cookie)", ) .await; assert!( !resp.status.is_success(), "javascript: scheme should be rejected: {} {}", resp.status, resp.text ); // data: scheme — another XSS vector let resp = h .client .post_form( "/api/links", "title=Data+Link&url=data:text/html,", ) .await; assert!( !resp.status.is_success(), "data: scheme should be rejected: {} {}", resp.status, resp.text ); }