Skip to main content

ferro_rs/validation/
error.rs

1//! Validation error types.
2
3use serde::Serialize;
4use std::collections::HashMap;
5
6/// A collection of validation errors.
7///
8/// Carries an optional `old_input` payload for the flash round-trip; set via
9/// `with_old_input()` before calling `redirect_back()` or `redirect_to()`.
10#[derive(Debug, Clone, Default, Serialize)]
11pub struct ValidationError {
12    /// Field-specific errors.
13    errors: HashMap<String, Vec<String>>,
14    /// Submitted form values to restore after a failed validation round-trip.
15    /// Stored separately from `errors` so it is never serialised into API error
16    /// responses (the `#[serde(skip)]` attribute).
17    #[serde(skip)]
18    old_input: Option<serde_json::Value>,
19}
20
21impl ValidationError {
22    /// Create a new empty validation error.
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Add an error message for a field.
28    pub fn add(&mut self, field: &str, message: impl Into<String>) {
29        self.errors
30            .entry(field.to_string())
31            .or_default()
32            .push(message.into());
33    }
34
35    /// Check if there are any errors.
36    pub fn is_empty(&self) -> bool {
37        self.errors.is_empty()
38    }
39
40    /// Check if a specific field has errors.
41    pub fn has(&self, field: &str) -> bool {
42        self.errors.contains_key(field)
43    }
44
45    /// Get errors for a specific field.
46    pub fn get(&self, field: &str) -> Option<&Vec<String>> {
47        self.errors.get(field)
48    }
49
50    /// Get the first error for a field.
51    pub fn first(&self, field: &str) -> Option<&String> {
52        self.errors.get(field).and_then(|v| v.first())
53    }
54
55    /// Get all errors as a map.
56    pub fn all(&self) -> &HashMap<String, Vec<String>> {
57        &self.errors
58    }
59
60    /// Get the total number of errors.
61    pub fn count(&self) -> usize {
62        self.errors.values().map(|v| v.len()).sum()
63    }
64
65    /// Get all error messages as a flat list.
66    pub fn messages(&self) -> Vec<&String> {
67        self.errors.values().flatten().collect()
68    }
69
70    /// Consume the error and return the inner HashMap of field -> messages.
71    /// Useful for passing errors to templates.
72    pub fn into_messages(self) -> HashMap<String, Vec<String>> {
73        self.errors
74    }
75
76    /// Convert to JSON-compatible format for API responses.
77    pub fn to_json(&self) -> serde_json::Value {
78        serde_json::json!({
79            "message": "The given data was invalid.",
80            "errors": self.errors
81        })
82    }
83
84    // ── Phase 137: flash round-trip helpers ───────────────────────────────────
85
86    /// Attach submitted form values as "old input" for the next GET request.
87    ///
88    /// Chain before `redirect_back()` or `redirect_to()`.  The caller typically
89    /// passes the same `serde_json::Value` used to construct the `Validator`.
90    ///
91    /// # Example
92    ///
93    /// ```ignore
94    /// if let Err(e) = validator.validate() {
95    ///     return e.with_old_input(&form_data).redirect_back(referer);
96    /// }
97    /// ```
98    pub fn with_old_input(mut self, data: &serde_json::Value) -> Self {
99        self.old_input = Some(data.clone());
100        self
101    }
102
103    /// Flash errors + old input into the session, then redirect to `referer`.
104    ///
105    /// Falls back to `"/"` when `referer` is `None` or when the Referer header
106    /// contains a non-same-origin URL (T-92-05 mitigation).
107    ///
108    /// # Example
109    ///
110    /// ```ignore
111    /// if let Err(e) = validator.validate() {
112    ///     let referer = req.header("Referer");
113    ///     return e.with_old_input(&form_data).redirect_back(referer);
114    /// }
115    /// ```
116    pub fn redirect_back(self, referer: Option<&str>) -> crate::http::Response {
117        // T-92-05: reject non-same-origin Referer values.
118        let target = match referer {
119            Some(r) if is_same_origin(r) => r.to_string(),
120            _ => "/".to_string(),
121        };
122        self.flash_into_session();
123        crate::http::Redirect::to(target).into()
124    }
125
126    /// Flash errors + old input into the session, then redirect to an explicit URL.
127    ///
128    /// Use this instead of `redirect_back()` when the calling controller knows
129    /// the exact destination (e.g. a tabbed settings form where the Referer may
130    /// lack the `?tab=...` parameter).
131    ///
132    /// # Example
133    ///
134    /// ```ignore
135    /// if let Err(e) = validator.validate() {
136    ///     return e.with_old_input(&form_data).redirect_to("/settings?tab=generale");
137    /// }
138    /// ```
139    pub fn redirect_to(self, url: impl Into<String>) -> crate::http::Response {
140        self.flash_into_session();
141        crate::http::Redirect::to(url.into()).into()
142    }
143
144    /// Write the error map and optional old input into the session flash store.
145    ///
146    /// Uses the reserved key prefix `_validation_errors` / `_old_input.<field>`
147    /// under `_flash.new.*` (T-92-03 namespace isolation).
148    fn flash_into_session(self) {
149        let errors = self.errors;
150        let old = self.old_input;
151        crate::session::session_mut(|session| {
152            session.flash("_validation_errors", &errors);
153            if let Some(serde_json::Value::Object(map)) = old {
154                for (k, v) in map {
155                    let stringified = match v {
156                        serde_json::Value::String(s) => s,
157                        serde_json::Value::Null => continue,
158                        other => other.to_string(),
159                    };
160                    session.flash(&format!("_old_input.{k}"), &stringified);
161                }
162            }
163        });
164    }
165}
166
167/// Returns `true` when `url` is a relative path or same-origin absolute URL.
168///
169/// Rejects any URL that has a scheme (`http://`, `https://`, etc.) pointing
170/// to a different origin.  A bare path like `/dashboard/prodotti` is always
171/// safe.  This is the T-92-05 Referer-forgery mitigation.
172fn is_same_origin(url: &str) -> bool {
173    // Relative paths are always safe.
174    if url.starts_with('/') {
175        return true;
176    }
177    // Absolute URLs with a scheme pointing to external hosts are rejected.
178    false
179}
180
181impl std::fmt::Display for ValidationError {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        let messages: Vec<String> = self
184            .errors
185            .iter()
186            .flat_map(|(field, msgs)| msgs.iter().map(move |m| format!("{field}: {m}")))
187            .collect();
188        write!(f, "{}", messages.join(", "))
189    }
190}
191
192impl std::error::Error for ValidationError {}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_validation_error_add() {
200        let mut errors = ValidationError::new();
201        errors.add("email", "The email field is required.");
202        errors.add("email", "The email must be a valid email address.");
203        errors.add("password", "The password must be at least 8 characters.");
204
205        assert!(!errors.is_empty());
206        assert!(errors.has("email"));
207        assert!(errors.has("password"));
208        assert!(!errors.has("name"));
209        assert_eq!(errors.count(), 3);
210    }
211
212    #[test]
213    fn test_validation_error_first() {
214        let mut errors = ValidationError::new();
215        errors.add("email", "First error");
216        errors.add("email", "Second error");
217
218        assert_eq!(errors.first("email"), Some(&"First error".to_string()));
219        assert_eq!(errors.first("name"), None);
220    }
221
222    #[test]
223    fn test_validation_error_to_json() {
224        let mut errors = ValidationError::new();
225        errors.add("email", "Required");
226
227        let json = errors.to_json();
228        assert!(json.get("message").is_some());
229        assert!(json.get("errors").is_some());
230    }
231
232    // ── Phase 137 tests: redirect_back / redirect_to / with_old_input ─────────
233
234    #[test]
235    fn test_redirect_back_returns_302_to_fallback_when_no_referer() {
236        let mut errors = ValidationError::new();
237        errors.add("email", "required");
238        let response = errors.redirect_back(None);
239        // redirect_back(None) must fall back to "/"
240        let resp = response.unwrap();
241        assert_eq!(resp.status_code(), 302);
242        let hyper_resp = resp.into_hyper();
243        let location = hyper_resp
244            .headers()
245            .get("Location")
246            .and_then(|v| v.to_str().ok());
247        assert_eq!(location, Some("/"));
248    }
249
250    #[test]
251    fn test_redirect_back_with_explicit_referer() {
252        let mut errors = ValidationError::new();
253        errors.add("name", "required");
254        let response = errors.redirect_back(Some("/dashboard/prodotti/nuovo"));
255        let resp = response.unwrap();
256        assert_eq!(resp.status_code(), 302);
257        let hyper_resp = resp.into_hyper();
258        let location = hyper_resp
259            .headers()
260            .get("Location")
261            .and_then(|v| v.to_str().ok());
262        assert_eq!(location, Some("/dashboard/prodotti/nuovo"));
263    }
264
265    #[test]
266    fn test_redirect_back_rejects_external_referer() {
267        // T-92-05: non-same-origin Referer must fall back to "/"
268        let mut errors = ValidationError::new();
269        errors.add("name", "required");
270        let response = errors.redirect_back(Some("https://evil.example.com/phishing"));
271        let resp = response.unwrap();
272        assert_eq!(resp.status_code(), 302);
273        let hyper_resp = resp.into_hyper();
274        let location = hyper_resp
275            .headers()
276            .get("Location")
277            .and_then(|v| v.to_str().ok());
278        assert_eq!(location, Some("/"));
279    }
280
281    #[test]
282    fn test_redirect_to_returns_302_to_explicit_url() {
283        let mut errors = ValidationError::new();
284        errors.add("slug", "invalid");
285        let response = errors.redirect_to("/settings?tab=generale");
286        let resp = response.unwrap();
287        assert_eq!(resp.status_code(), 302);
288        let hyper_resp = resp.into_hyper();
289        let location = hyper_resp
290            .headers()
291            .get("Location")
292            .and_then(|v| v.to_str().ok());
293        assert_eq!(location, Some("/settings?tab=generale"));
294    }
295
296    #[test]
297    fn test_with_old_input_chaining() {
298        // Verify with_old_input() is chainable and does not panic.
299        // We cannot inspect session flash in a unit test (no task-local context),
300        // but we verify the method compiles and returns Self.
301        let mut errors = ValidationError::new();
302        errors.add("email", "required");
303        let data = serde_json::json!({"email": "bad@"});
304        // Should not panic; returns a new ValidationError with old_input set.
305        let e = errors.with_old_input(&data);
306        assert!(!e.is_empty());
307    }
308}