//! Adversarial input validation & edge case tests.
//!
//! Focus: input validation and edge cases.
//! 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/{bad_id}/versions")).await;
assert_eq!(
resp.status, 400,
"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/{project_id}/items"),
"title=Evil+Item&item_type=digital&price_cents=-100",
)
.await;
assert_eq!(
resp.status, 422,
"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/{project_id}/items"),
"title=Expensive&item_type=digital&price_cents=2000000000",
)
.await;
assert_eq!(
resp.status, 422,
"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/{project_id}/items"),
"title=Free+Item&item_type=digital&price_cents=0",
)
.await;
assert_eq!(
resp.status, 200,
"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/{project_id}/items"),
&format!("title={long_title}&item_type=digital"),
)
.await;
assert_eq!(
resp.status, 422,
"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/{project_id}/items"),
&format!("title=Normal&item_type=digital&description={long_desc}"),
)
.await;
assert_eq!(
resp.status, 422,
"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/{project_id}/items"),
"title=&item_type=digital",
)
.await;
assert_eq!(
resp.status, 422,
"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/{project_id}/items"),
&format!("title={xss_title}&item_type=digital"),
)
.await;
assert_eq!(
resp.status, 200,
"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/{project_id}/items"),
&format!("title={sqli_title}&item_type=digital"),
)
.await;
assert_eq!(
resp.status, 200,
"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_eq!(
resp.status, 200,
"Projects list should still work after SQL injection attempt"
);
}
// Duplicate creation
/// Vulnerability tested: a duplicate project slug must never overwrite the
/// existing project or surface a raw 500. The create path auto-suffixes the
/// collision (`inputtest-proj` -> `inputtest-proj-2`) via `insert_with_unique_slug`
/// and retries. The second project gets a fresh
/// id and a distinct slug, so no confusion or overwrite is possible.
#[tokio::test]
async fn duplicate_project_slug_auto_suffixed() {
let mut h = TestHarness::new().await;
let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
// Creating another project with the same slug succeeds with a suffixed slug.
let resp = h
.client
.post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop")
.await;
assert_eq!(
resp.status, 200,
"Duplicate slug should auto-suffix, not fail: {} {}",
resp.status, resp.text
);
let second: serde_json::Value = resp.json();
assert_eq!(
second["slug"], "inputtest-proj-2",
"collision must auto-suffix to inputtest-proj-2: {second}"
);
assert_ne!(
second["id"].as_str().unwrap(),
project_id,
"the duplicate must be a new project, never an overwrite"
);
}
/// Vulnerability tested: Duplicate username on signup.
/// Second signup with same username should be rejected.
///
/// The `auth.rs` sibling asserts what the user is told; this one asserts what
/// the table holds, which is the part an attacker cares about. Both spent their
/// lives POSTing GET-only `/join` and passing on the 405, so neither had ever
/// reached the uniqueness check.
#[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
h.client.fetch_csrf_token().await;
let resp = h
.client
.post_form(
"/join/step/account",
"username=dupuser&email=dup2@test.com&password=password456",
)
.await;
assert_eq!(resp.status, 200, "{}", resp.text);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'dupuser'")
.fetch_one(&h.db)
.await
.unwrap();
assert_eq!(
count, 1,
"the duplicate signup must not create a second row"
);
let stole: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'dup2@test.com'")
.fetch_one(&h.db)
.await
.unwrap();
assert_eq!(
stole, 0,
"and must not create an account under the new email"
);
}
// 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_eq!(
resp.status, 422,
"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_eq!(
resp.status, 200,
"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={slug_100}&title=Max+Slug"))
.await;
assert_eq!(
resp.status, 200,
"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={slug_101}&title=Over+Max"))
.await;
assert_eq!(
resp.status, 422,
"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_eq!(
resp.status, 422,
"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/{project_id}/items"),
&format!("title={cjk_200}&item_type=digital"),
)
.await;
assert_eq!(
resp.status, 200,
"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/{project_id}/items"),
&format!("title={cjk_201}&item_type=digital"),
)
.await;
assert_eq!(
resp.status, 422,
"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_eq!(
resp.status, 422,
"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_eq!(
resp.status, 400,
"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_eq!(
resp.status, 422,
"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_eq!(
resp.status, 422,
"data: scheme should be rejected: {} {}",
resp.status, resp.text
);
}