Skip to main content

max / makenotwork

8.6 KB · 252 lines History Blame Raw
1 //! Input validation + parsing helpers that map failures to HTTP responses.
2
3 use axum::{
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 };
7 use chrono::{DateTime, Duration, Utc};
8 use uuid::Uuid;
9
10 /// Parse a UUID from a string, returning 404 on failure.
11 #[allow(clippy::result_large_err)]
12 pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> {
13 Uuid::parse_str(id_str).map_err(|_| crate::error_page::not_found())
14 }
15
16 /// Build a 422 validation response tagged with the offending form field.
17 ///
18 /// `field` matches the input's `name` attribute. The `X-Form-Field` header lets
19 /// `mt.js` render the message as a persistent inline error next to that input
20 /// (and focus it) instead of a transient toast, the A-grade form-failure UX.
21 /// Submissions never navigate away on 422, so the user's input is preserved in
22 /// the live DOM; only the error needs to come back.
23 #[allow(clippy::result_large_err)]
24 pub(crate) fn field_error(field: &'static str, message: impl Into<String>) -> Response {
25 (
26 StatusCode::UNPROCESSABLE_ENTITY,
27 [("X-Form-Field", field)],
28 message.into(),
29 )
30 .into_response()
31 }
32
33 /// Validate a title field (1-256 chars).
34 #[allow(clippy::result_large_err)]
35 pub(crate) fn validate_title(text: &str) -> Result<&str, Response> {
36 let t = text.trim();
37 // Count characters, not bytes, to match the client `maxlength` (UTF-16
38 // units), else a multibyte title under the visible cap fails server-side.
39 if t.is_empty() || t.chars().count() > 256 {
40 return Err(field_error(
41 "title",
42 "Title must be between 1 and 256 characters.",
43 ));
44 }
45 Ok(t)
46 }
47
48 /// Validate a body/content field (1 to max chars). `label` names the field in
49 /// the message ("Body", "Footnote"); the inline error attaches to the `body`
50 /// input, which every content textarea in the app uses as its `name`.
51 #[allow(clippy::result_large_err)]
52 pub(crate) fn validate_body<'a>(
53 text: &'a str,
54 max: usize,
55 label: &str,
56 ) -> Result<&'a str, Response> {
57 let t = text.trim();
58 // Character count (not byte length) to match the client-side cap.
59 if t.is_empty() || t.chars().count() > max {
60 return Err(field_error(
61 "body",
62 format!("{label} must be between 1 and {max} characters."),
63 ));
64 }
65 Ok(t)
66 }
67
68 /// Parse a ban duration string into an optional expiration datetime.
69 /// Returns `Err` for unrecognized durations to prevent accidental permanent bans.
70 #[allow(clippy::result_large_err)]
71 pub(crate) fn parse_duration(duration: &str) -> Result<Option<DateTime<Utc>>, Response> {
72 match duration {
73 "permanent" => Ok(None),
74 "1h" => Ok(Some(Utc::now() + Duration::hours(1))),
75 "1d" => Ok(Some(Utc::now() + Duration::days(1))),
76 "7d" => Ok(Some(Utc::now() + Duration::days(7))),
77 "30d" => Ok(Some(Utc::now() + Duration::days(30))),
78 _ => Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid duration.").into_response()),
79 }
80 }
81
82 #[cfg(test)]
83 mod validation_tests {
84 use super::*;
85
86 // --- validate_title (1..=256 chars after trim)
87
88 #[test]
89 fn title_rejects_empty() {
90 assert!(validate_title("").is_err());
91 }
92
93 #[test]
94 fn title_rejects_whitespace_only() {
95 // The function trims first, so whitespace-only collapses to empty.
96 assert!(validate_title(" \t\n ").is_err());
97 }
98
99 #[test]
100 fn title_accepts_single_char() {
101 assert_eq!(validate_title("x").unwrap(), "x");
102 }
103
104 #[test]
105 fn title_trims_surrounding_whitespace() {
106 // Pins the `.trim()` step: returned slice excludes leading/trailing whitespace.
107 assert_eq!(validate_title(" hello ").unwrap(), "hello");
108 }
109
110 #[test]
111 fn title_accepts_exactly_256_chars() {
112 let s = "a".repeat(256);
113 assert_eq!(validate_title(&s).unwrap().len(), 256);
114 }
115
116 #[test]
117 fn title_rejects_257_chars() {
118 // Pins `t.len() > 256` vs `>= 256`. At exactly 257, must reject.
119 let s = "a".repeat(257);
120 assert!(validate_title(&s).is_err());
121 }
122
123 #[test]
124 fn title_counts_characters_not_bytes() {
125 // 256 multibyte chars = 1024 bytes. Must pass (char count), matching the
126 // client `maxlength`; a byte-length check would wrongly reject it.
127 let s = "Ć©".repeat(256);
128 assert!(validate_title(&s).is_ok(), "256 multibyte chars must pass");
129 // 257 of them must still reject on character count.
130 let s = "Ć©".repeat(257);
131 assert!(validate_title(&s).is_err(), "257 chars must reject");
132 }
133
134 // --- validate_body (1..=max chars after trim)
135
136 #[test]
137 fn body_rejects_empty() {
138 assert!(validate_body("", 100, "Body").is_err());
139 }
140
141 #[test]
142 fn body_accepts_one_char() {
143 assert_eq!(validate_body("x", 100, "Body").unwrap(), "x");
144 }
145
146 #[test]
147 fn body_accepts_at_exact_max() {
148 let s = "a".repeat(50);
149 assert_eq!(validate_body(&s, 50, "Body").unwrap().len(), 50);
150 }
151
152 #[test]
153 fn body_rejects_one_over_max() {
154 // Pins `t.len() > max` vs `>= max`. Length max+1 must reject.
155 let s = "a".repeat(51);
156 assert!(validate_body(&s, 50, "Body").is_err());
157 }
158
159 #[test]
160 fn body_trims_before_length_check() {
161 // Trimmed length, not raw length, is what counts. " a " (3 raw) → "a"
162 // (1 trimmed) fits within max=1.
163 assert_eq!(validate_body(" a ", 1, "Body").unwrap(), "a");
164 }
165
166 #[test]
167 fn body_or_chain_requires_either_condition() {
168 // Pins `is_empty() || len > max` vs `&&` (which would never reject).
169 // An empty body alone (under max) must reject.
170 assert!(validate_body(" ", 100, "Body").is_err());
171 // A too-long body alone (non-empty) must reject.
172 let s = "x".repeat(101);
173 assert!(validate_body(&s, 100, "Body").is_err());
174 }
175
176 #[test]
177 fn parse_duration_permanent_returns_none() {
178 // Pins the `"permanent" => Ok(None)` arm; a mutation swapping arms
179 // would either reject "permanent" or produce a non-None expiry.
180 let r = parse_duration("permanent").unwrap();
181 assert!(r.is_none());
182 }
183
184 #[test]
185 fn parse_duration_known_strings_produce_offsets() {
186 // Each known string maps to a specific Duration arm. Assert the
187 // returned datetime is roughly the expected offset from now.
188 let before = Utc::now();
189 let h1 = parse_duration("1h").unwrap().unwrap();
190 let after = Utc::now();
191 let expected_min = before + Duration::hours(1);
192 let expected_max = after + Duration::hours(1);
193 assert!(h1 >= expected_min - Duration::seconds(2));
194 assert!(h1 <= expected_max + Duration::seconds(2));
195
196 let d1 = parse_duration("1d").unwrap().unwrap();
197 let d7 = parse_duration("7d").unwrap().unwrap();
198 let d30 = parse_duration("30d").unwrap().unwrap();
199 // Ordering must be strict (catches arm-swap mutations).
200 assert!(h1 < d1);
201 assert!(d1 < d7);
202 assert!(d7 < d30);
203 }
204
205 #[test]
206 fn parse_duration_arm_offsets_are_distinct() {
207 // Compare relative day gaps. From d1 to d7 should be ~6 days, from
208 // d7 to d30 should be ~23 days. Catches mutations swapping arms.
209 let d1 = parse_duration("1d").unwrap().unwrap();
210 let d7 = parse_duration("7d").unwrap().unwrap();
211 let d30 = parse_duration("30d").unwrap().unwrap();
212
213 let gap_1_to_7 = (d7 - d1).num_days();
214 let gap_7_to_30 = (d30 - d7).num_days();
215 assert!((5..=7).contains(&gap_1_to_7), "1d→7d gap was {gap_1_to_7}");
216 assert!(
217 (22..=24).contains(&gap_7_to_30),
218 "7d→30d gap was {gap_7_to_30}"
219 );
220 }
221
222 #[test]
223 fn parse_duration_unknown_is_rejected() {
224 // Pins the `_ => Err(...)` arm: anything not in the allowlist must
225 // return Err rather than (e.g.) defaulting to permanent.
226 assert!(parse_duration("forever").is_err());
227 assert!(parse_duration("").is_err());
228 assert!(
229 parse_duration("1d ").is_err(),
230 "trailing whitespace must not match"
231 );
232 assert!(
233 parse_duration("1H").is_err(),
234 "case-sensitive: 1H is not 1h"
235 );
236 }
237
238 #[test]
239 fn parse_uuid_valid_string() {
240 let raw = "11111111-2222-3333-4444-555555555555";
241 let parsed = parse_uuid(raw).unwrap();
242 assert_eq!(parsed.to_string(), raw);
243 }
244
245 #[test]
246 fn parse_uuid_invalid_string_is_err() {
247 assert!(parse_uuid("not-a-uuid").is_err());
248 assert!(parse_uuid("").is_err());
249 assert!(parse_uuid("11111111-2222-3333-4444").is_err());
250 }
251 }
252