//! Input validation + parsing helpers that map failures to HTTP responses. use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; /// Parse a UUID from a string, returning 404 on failure. #[allow(clippy::result_large_err)] pub(crate) fn parse_uuid(id_str: &str) -> Result { Uuid::parse_str(id_str).map_err(|_| crate::error_page::not_found()) } /// Build a 422 validation response tagged with the offending form field. /// /// `field` matches the input's `name` attribute. The `X-Form-Field` header lets /// `mt.js` render the message as a persistent inline error next to that input /// (and focus it) instead of a transient toast, the A-grade form-failure UX. /// Submissions never navigate away on 422, so the user's input is preserved in /// the live DOM; only the error needs to come back. #[allow(clippy::result_large_err)] pub(crate) fn field_error(field: &'static str, message: impl Into) -> Response { ( StatusCode::UNPROCESSABLE_ENTITY, [("X-Form-Field", field)], message.into(), ) .into_response() } /// Validate a title field (1-256 chars). #[allow(clippy::result_large_err)] pub(crate) fn validate_title(text: &str) -> Result<&str, Response> { let t = text.trim(); // Count characters, not bytes, to match the client `maxlength` (UTF-16 // units), else a multibyte title under the visible cap fails server-side. if t.is_empty() || t.chars().count() > 256 { return Err(field_error( "title", "Title must be between 1 and 256 characters.", )); } Ok(t) } /// Validate a body/content field (1 to max chars). `label` names the field in /// the message ("Body", "Footnote"); the inline error attaches to the `body` /// input, which every content textarea in the app uses as its `name`. #[allow(clippy::result_large_err)] pub(crate) fn validate_body<'a>( text: &'a str, max: usize, label: &str, ) -> Result<&'a str, Response> { let t = text.trim(); // Character count (not byte length) to match the client-side cap. if t.is_empty() || t.chars().count() > max { return Err(field_error( "body", format!("{label} must be between 1 and {max} characters."), )); } Ok(t) } /// Parse a ban duration string into an optional expiration datetime. /// Returns `Err` for unrecognized durations to prevent accidental permanent bans. #[allow(clippy::result_large_err)] pub(crate) fn parse_duration(duration: &str) -> Result>, Response> { match duration { "permanent" => Ok(None), "1h" => Ok(Some(Utc::now() + Duration::hours(1))), "1d" => Ok(Some(Utc::now() + Duration::days(1))), "7d" => Ok(Some(Utc::now() + Duration::days(7))), "30d" => Ok(Some(Utc::now() + Duration::days(30))), _ => Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid duration.").into_response()), } } #[cfg(test)] mod validation_tests { use super::*; // --- validate_title (1..=256 chars after trim) #[test] fn title_rejects_empty() { assert!(validate_title("").is_err()); } #[test] fn title_rejects_whitespace_only() { // The function trims first, so whitespace-only collapses to empty. assert!(validate_title(" \t\n ").is_err()); } #[test] fn title_accepts_single_char() { assert_eq!(validate_title("x").unwrap(), "x"); } #[test] fn title_trims_surrounding_whitespace() { // Pins the `.trim()` step: returned slice excludes leading/trailing whitespace. assert_eq!(validate_title(" hello ").unwrap(), "hello"); } #[test] fn title_accepts_exactly_256_chars() { let s = "a".repeat(256); assert_eq!(validate_title(&s).unwrap().len(), 256); } #[test] fn title_rejects_257_chars() { // Pins `t.len() > 256` vs `>= 256`. At exactly 257, must reject. let s = "a".repeat(257); assert!(validate_title(&s).is_err()); } #[test] fn title_counts_characters_not_bytes() { // 256 multibyte chars = 1024 bytes. Must pass (char count), matching the // client `maxlength`; a byte-length check would wrongly reject it. let s = "é".repeat(256); assert!(validate_title(&s).is_ok(), "256 multibyte chars must pass"); // 257 of them must still reject on character count. let s = "é".repeat(257); assert!(validate_title(&s).is_err(), "257 chars must reject"); } // --- validate_body (1..=max chars after trim) #[test] fn body_rejects_empty() { assert!(validate_body("", 100, "Body").is_err()); } #[test] fn body_accepts_one_char() { assert_eq!(validate_body("x", 100, "Body").unwrap(), "x"); } #[test] fn body_accepts_at_exact_max() { let s = "a".repeat(50); assert_eq!(validate_body(&s, 50, "Body").unwrap().len(), 50); } #[test] fn body_rejects_one_over_max() { // Pins `t.len() > max` vs `>= max`. Length max+1 must reject. let s = "a".repeat(51); assert!(validate_body(&s, 50, "Body").is_err()); } #[test] fn body_trims_before_length_check() { // Trimmed length, not raw length, is what counts. " a " (3 raw) → "a" // (1 trimmed) fits within max=1. assert_eq!(validate_body(" a ", 1, "Body").unwrap(), "a"); } #[test] fn body_or_chain_requires_either_condition() { // Pins `is_empty() || len > max` vs `&&` (which would never reject). // An empty body alone (under max) must reject. assert!(validate_body(" ", 100, "Body").is_err()); // A too-long body alone (non-empty) must reject. let s = "x".repeat(101); assert!(validate_body(&s, 100, "Body").is_err()); } #[test] fn parse_duration_permanent_returns_none() { // Pins the `"permanent" => Ok(None)` arm; a mutation swapping arms // would either reject "permanent" or produce a non-None expiry. let r = parse_duration("permanent").unwrap(); assert!(r.is_none()); } #[test] fn parse_duration_known_strings_produce_offsets() { // Each known string maps to a specific Duration arm. Assert the // returned datetime is roughly the expected offset from now. let before = Utc::now(); let h1 = parse_duration("1h").unwrap().unwrap(); let after = Utc::now(); let expected_min = before + Duration::hours(1); let expected_max = after + Duration::hours(1); assert!(h1 >= expected_min - Duration::seconds(2)); assert!(h1 <= expected_max + Duration::seconds(2)); let d1 = parse_duration("1d").unwrap().unwrap(); let d7 = parse_duration("7d").unwrap().unwrap(); let d30 = parse_duration("30d").unwrap().unwrap(); // Ordering must be strict (catches arm-swap mutations). assert!(h1 < d1); assert!(d1 < d7); assert!(d7 < d30); } #[test] fn parse_duration_arm_offsets_are_distinct() { // Compare relative day gaps. From d1 to d7 should be ~6 days, from // d7 to d30 should be ~23 days. Catches mutations swapping arms. let d1 = parse_duration("1d").unwrap().unwrap(); let d7 = parse_duration("7d").unwrap().unwrap(); let d30 = parse_duration("30d").unwrap().unwrap(); let gap_1_to_7 = (d7 - d1).num_days(); let gap_7_to_30 = (d30 - d7).num_days(); assert!((5..=7).contains(&gap_1_to_7), "1d→7d gap was {gap_1_to_7}"); assert!( (22..=24).contains(&gap_7_to_30), "7d→30d gap was {gap_7_to_30}" ); } #[test] fn parse_duration_unknown_is_rejected() { // Pins the `_ => Err(...)` arm: anything not in the allowlist must // return Err rather than (e.g.) defaulting to permanent. assert!(parse_duration("forever").is_err()); assert!(parse_duration("").is_err()); assert!( parse_duration("1d ").is_err(), "trailing whitespace must not match" ); assert!( parse_duration("1H").is_err(), "case-sensitive: 1H is not 1h" ); } #[test] fn parse_uuid_valid_string() { let raw = "11111111-2222-3333-4444-555555555555"; let parsed = parse_uuid(raw).unwrap(); assert_eq!(parsed.to_string(), raw); } #[test] fn parse_uuid_invalid_string_is_err() { assert!(parse_uuid("not-a-uuid").is_err()); assert!(parse_uuid("").is_err()); assert!(parse_uuid("11111111-2222-3333-4444").is_err()); } }