//! 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
);
}