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    /// Flash per-field errors + old input into the session, then produce an
145    /// `ActionError` configured to redirect to `url` WITHOUT writing the URL
146    /// `?error=...&msg=...` envelope (the per-field errors already carry the
147    /// user-visible message — a generic envelope toast would be redundant).
148    ///
149    /// This is the consumer-side replacement for the "discard-Response" idiom:
150    ///
151    /// ```ignore
152    /// // BEFORE — chains `redirect_to` for its session flash side-effect, then
153    /// // returns an ActionError that adds a redundant `?error=generic&msg=...`:
154    /// let _ = errors.with_old_input(&data).redirect_to(&back_url);
155    /// return Err(ActionError::msg("Dati non validi").redirect_to(&back_url));
156    ///
157    /// // AFTER — single chain; per-field errors flash, no envelope:
158    /// return Err(errors.with_old_input(&data).into_action_error(&back_url));
159    /// ```
160    pub fn into_action_error(self, url: impl Into<String>) -> crate::http::action::ActionError {
161        self.flash_into_session();
162        crate::http::action::ActionError::validation_failed(url)
163    }
164
165    /// Write the error map and optional old input into the session flash store.
166    ///
167    /// Uses the reserved key prefix `_validation_errors` / `_old_input.<field>`
168    /// under `_flash.new.*` (T-92-03 namespace isolation).
169    fn flash_into_session(self) {
170        let errors = self.errors;
171        let old = self.old_input;
172        crate::session::session_mut(|session| {
173            session.flash("_validation_errors", &errors);
174            if let Some(serde_json::Value::Object(map)) = old {
175                for (k, v) in map {
176                    let stringified = match v {
177                        serde_json::Value::String(s) => s,
178                        serde_json::Value::Null => continue,
179                        other => other.to_string(),
180                    };
181                    session.flash(&format!("_old_input.{k}"), &stringified);
182                }
183            }
184        });
185    }
186}
187
188/// Returns `true` when `url` is a relative path or same-origin absolute URL.
189///
190/// Rejects any URL that has a scheme (`http://`, `https://`, etc.) pointing
191/// to a different origin.  A bare path like `/dashboard/prodotti` is always
192/// safe.  This is the T-92-05 Referer-forgery mitigation.
193fn is_same_origin(url: &str) -> bool {
194    // Relative paths are always safe.
195    if url.starts_with('/') {
196        return true;
197    }
198    // Absolute URLs with a scheme pointing to external hosts are rejected.
199    false
200}
201
202impl std::fmt::Display for ValidationError {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        let messages: Vec<String> = self
205            .errors
206            .iter()
207            .flat_map(|(field, msgs)| msgs.iter().map(move |m| format!("{field}: {m}")))
208            .collect();
209        write!(f, "{}", messages.join(", "))
210    }
211}
212
213impl std::error::Error for ValidationError {}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_validation_error_add() {
221        let mut errors = ValidationError::new();
222        errors.add("email", "The email field is required.");
223        errors.add("email", "The email must be a valid email address.");
224        errors.add("password", "The password must be at least 8 characters.");
225
226        assert!(!errors.is_empty());
227        assert!(errors.has("email"));
228        assert!(errors.has("password"));
229        assert!(!errors.has("name"));
230        assert_eq!(errors.count(), 3);
231    }
232
233    #[test]
234    fn test_validation_error_first() {
235        let mut errors = ValidationError::new();
236        errors.add("email", "First error");
237        errors.add("email", "Second error");
238
239        assert_eq!(errors.first("email"), Some(&"First error".to_string()));
240        assert_eq!(errors.first("name"), None);
241    }
242
243    #[test]
244    fn test_validation_error_to_json() {
245        let mut errors = ValidationError::new();
246        errors.add("email", "Required");
247
248        let json = errors.to_json();
249        assert!(json.get("message").is_some());
250        assert!(json.get("errors").is_some());
251    }
252
253    // ── Phase 137 tests: redirect_back / redirect_to / with_old_input ─────────
254
255    #[test]
256    fn test_redirect_back_returns_302_to_fallback_when_no_referer() {
257        let mut errors = ValidationError::new();
258        errors.add("email", "required");
259        let response = errors.redirect_back(None);
260        // redirect_back(None) must fall back to "/"
261        let resp = response.unwrap();
262        assert_eq!(resp.status_code(), 302);
263        let hyper_resp = resp.into_hyper();
264        let location = hyper_resp
265            .headers()
266            .get("Location")
267            .and_then(|v| v.to_str().ok());
268        assert_eq!(location, Some("/"));
269    }
270
271    #[test]
272    fn test_redirect_back_with_explicit_referer() {
273        let mut errors = ValidationError::new();
274        errors.add("name", "required");
275        let response = errors.redirect_back(Some("/dashboard/prodotti/nuovo"));
276        let resp = response.unwrap();
277        assert_eq!(resp.status_code(), 302);
278        let hyper_resp = resp.into_hyper();
279        let location = hyper_resp
280            .headers()
281            .get("Location")
282            .and_then(|v| v.to_str().ok());
283        assert_eq!(location, Some("/dashboard/prodotti/nuovo"));
284    }
285
286    #[test]
287    fn test_redirect_back_rejects_external_referer() {
288        // T-92-05: non-same-origin Referer must fall back to "/"
289        let mut errors = ValidationError::new();
290        errors.add("name", "required");
291        let response = errors.redirect_back(Some("https://evil.example.com/phishing"));
292        let resp = response.unwrap();
293        assert_eq!(resp.status_code(), 302);
294        let hyper_resp = resp.into_hyper();
295        let location = hyper_resp
296            .headers()
297            .get("Location")
298            .and_then(|v| v.to_str().ok());
299        assert_eq!(location, Some("/"));
300    }
301
302    #[test]
303    fn test_redirect_to_returns_302_to_explicit_url() {
304        let mut errors = ValidationError::new();
305        errors.add("slug", "invalid");
306        let response = errors.redirect_to("/settings?tab=generale");
307        let resp = response.unwrap();
308        assert_eq!(resp.status_code(), 302);
309        let hyper_resp = resp.into_hyper();
310        let location = hyper_resp
311            .headers()
312            .get("Location")
313            .and_then(|v| v.to_str().ok());
314        assert_eq!(location, Some("/settings?tab=generale"));
315    }
316
317    #[test]
318    fn test_with_old_input_chaining() {
319        // Verify with_old_input() is chainable and does not panic.
320        // We cannot inspect session flash in a unit test (no task-local context),
321        // but we verify the method compiles and returns Self.
322        let mut errors = ValidationError::new();
323        errors.add("email", "required");
324        let data = serde_json::json!({"email": "bad@"});
325        // Should not panic; returns a new ValidationError with old_input set.
326        let e = errors.with_old_input(&data);
327        assert!(!e.is_empty());
328    }
329}