Skip to main content

rustrails_model/validations/
confirmation.rs

1use rustrails_support::inflector::humanize;
2use serde_json::Value;
3
4use super::{Validator, ValidatorOptions};
5use crate::errors::{ErrorType, Errors};
6
7/// Validates that an attribute matches a sibling confirmation attribute.
8#[derive(Debug, Clone)]
9pub struct ConfirmationValidator {
10    confirmation_attribute: String,
11    case_sensitive: bool,
12    message: Option<String>,
13    pub(crate) options: ValidatorOptions,
14}
15
16impl ConfirmationValidator {
17    /// Creates a new confirmation validator.
18    #[must_use]
19    pub fn new(confirmation_attribute: &str) -> Self {
20        Self {
21            confirmation_attribute: confirmation_attribute.to_owned(),
22            case_sensitive: true,
23            message: None,
24            options: ValidatorOptions::default(),
25        }
26    }
27
28    crate::validations::impl_common_validator_methods!();
29
30    /// Enables or disables case-sensitive comparison for string values.
31    #[must_use]
32    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
33        self.case_sensitive = case_sensitive;
34        self
35    }
36
37    /// Overrides the default confirmation message.
38    #[must_use]
39    pub fn message(mut self, message: impl Into<String>) -> Self {
40        self.message = Some(message.into());
41        self
42    }
43
44    fn error_message(&self, attribute: &str) -> String {
45        self.message
46            .clone()
47            .unwrap_or_else(|| format!("doesn't match {}", humanize(attribute)))
48    }
49
50    fn matches(&self, value: Option<&Value>, confirmation: &Value) -> bool {
51        match (value, confirmation) {
52            (Some(Value::String(left)), Value::String(right)) if !self.case_sensitive => {
53                left.to_lowercase() == right.to_lowercase()
54            }
55            (Some(left), right) => left == right,
56            (None, Value::Null) => true,
57            _ => false,
58        }
59    }
60}
61
62impl Validator for ConfirmationValidator {
63    fn validate(&self, _attribute: &str, _value: Option<&Value>, _errors: &mut Errors) {}
64
65    fn validate_with_attrs(
66        &self,
67        attribute: &str,
68        value: Option<&Value>,
69        attrs: &dyn Fn(&str) -> Option<Value>,
70        errors: &mut Errors,
71    ) {
72        let Some(confirmation) = attrs(&self.confirmation_attribute) else {
73            return;
74        };
75
76        if confirmation.is_null() {
77            return;
78        }
79
80        if !self.matches(value, &confirmation) {
81            errors.add(
82                &self.confirmation_attribute,
83                ErrorType::Confirmation,
84                self.error_message(attribute),
85            );
86        }
87    }
88
89    fn name(&self) -> &str {
90        "confirmation"
91    }
92
93    fn options(&self) -> &ValidatorOptions {
94        &self.options
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::collections::HashMap;
101
102    use serde_json::json;
103
104    use super::ConfirmationValidator;
105    use crate::{
106        errors::{ErrorType, Errors},
107        validations::{ValidationSet, Validator},
108    };
109
110    fn validate_confirmation(
111        validator: &ConfirmationValidator,
112        attribute: &str,
113        value: Option<&serde_json::Value>,
114        attrs: &HashMap<String, serde_json::Value>,
115    ) -> Errors {
116        let mut errors = Errors::new();
117        validator.validate_with_attrs(
118            attribute,
119            value,
120            &|name| attrs.get(name).cloned(),
121            &mut errors,
122        );
123        errors
124    }
125
126    #[test]
127    fn missing_confirmation_attribute_is_ignored() {
128        let validator = ConfirmationValidator::new("password_confirmation");
129        let attrs = HashMap::from([(String::from("password"), json!("secret"))]);
130        let mut errors = Errors::new();
131
132        validator.validate_with_attrs(
133            "password",
134            attrs.get("password"),
135            &|name| attrs.get(name).cloned(),
136            &mut errors,
137        );
138
139        assert!(errors.is_empty());
140    }
141
142    #[test]
143    fn mismatch_adds_error_on_confirmation_attribute() {
144        let validator = ConfirmationValidator::new("password_confirmation");
145        let attrs = HashMap::from([
146            (String::from("password"), json!("secret")),
147            (String::from("password_confirmation"), json!("other")),
148        ]);
149        let mut errors = Errors::new();
150
151        validator.validate_with_attrs(
152            "password",
153            attrs.get("password"),
154            &|name| attrs.get(name).cloned(),
155            &mut errors,
156        );
157
158        assert_eq!(
159            errors.on("password_confirmation")[0].error_type,
160            ErrorType::Confirmation
161        );
162    }
163
164    #[test]
165    fn matching_values_pass() {
166        let validator = ConfirmationValidator::new("email_confirmation");
167        let attrs = HashMap::from([
168            (String::from("email"), json!("alice@example.com")),
169            (
170                String::from("email_confirmation"),
171                json!("alice@example.com"),
172            ),
173        ]);
174        let mut errors = Errors::new();
175
176        validator.validate_with_attrs(
177            "email",
178            attrs.get("email"),
179            &|name| attrs.get(name).cloned(),
180            &mut errors,
181        );
182
183        assert!(errors.is_empty());
184    }
185
186    #[test]
187    fn case_insensitive_strings_can_match() {
188        let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(false);
189        let attrs = HashMap::from([
190            (String::from("email"), json!("Alice@example.com")),
191            (
192                String::from("email_confirmation"),
193                json!("alice@example.com"),
194            ),
195        ]);
196        let mut errors = Errors::new();
197
198        validator.validate_with_attrs(
199            "email",
200            attrs.get("email"),
201            &|name| attrs.get(name).cloned(),
202            &mut errors,
203        );
204
205        assert!(errors.is_empty());
206    }
207
208    #[test]
209    fn validation_set_uses_sibling_attribute_lookup() {
210        let mut set = ValidationSet::new();
211        set.add(
212            "password",
213            ConfirmationValidator::new("password_confirmation"),
214        );
215        let attrs = HashMap::from([
216            (String::from("password"), json!("secret")),
217            (String::from("password_confirmation"), json!("other")),
218        ]);
219        let mut errors = Errors::new();
220
221        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
222
223        assert_eq!(
224            errors.on("password_confirmation")[0].message,
225            "doesn't match Password"
226        );
227    }
228
229    #[test]
230    fn null_confirmation_is_ignored() {
231        let validator = ConfirmationValidator::new("password_confirmation");
232        let attrs = HashMap::from([
233            (String::from("password"), json!("secret")),
234            (String::from("password_confirmation"), json!(null)),
235        ]);
236
237        let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);
238
239        assert!(errors.is_empty());
240    }
241
242    #[test]
243    fn missing_primary_value_with_non_null_confirmation_fails() {
244        let validator = ConfirmationValidator::new("password_confirmation");
245        let attrs = HashMap::from([(String::from("password_confirmation"), json!("secret"))]);
246
247        let errors = validate_confirmation(&validator, "password", None, &attrs);
248
249        assert_eq!(
250            errors.on("password_confirmation")[0].error_type,
251            ErrorType::Confirmation
252        );
253    }
254
255    #[test]
256    fn custom_message_override_is_used() {
257        let validator = ConfirmationValidator::new("password_confirmation").message("must match");
258        let attrs = HashMap::from([
259            (String::from("password"), json!("secret")),
260            (String::from("password_confirmation"), json!("other")),
261        ]);
262
263        let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);
264
265        assert_eq!(errors.on("password_confirmation")[0].message, "must match");
266    }
267
268    #[test]
269    fn explicit_case_sensitive_matching_rejects_casing_difference() {
270        let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(true);
271        let attrs = HashMap::from([
272            (String::from("email"), json!("Alice@example.com")),
273            (
274                String::from("email_confirmation"),
275                json!("alice@example.com"),
276            ),
277        ]);
278
279        let errors = validate_confirmation(&validator, "email", attrs.get("email"), &attrs);
280
281        assert_eq!(
282            errors.on("email_confirmation")[0].error_type,
283            ErrorType::Confirmation
284        );
285    }
286
287    #[test]
288    fn case_insensitive_matching_still_rejects_different_strings() {
289        let validator = ConfirmationValidator::new("email_confirmation").case_sensitive(false);
290        let attrs = HashMap::from([
291            (String::from("email"), json!("alice@example.com")),
292            (String::from("email_confirmation"), json!("bob@example.com")),
293        ]);
294
295        let errors = validate_confirmation(&validator, "email", attrs.get("email"), &attrs);
296
297        assert_eq!(
298            errors.on("email_confirmation")[0].error_type,
299            ErrorType::Confirmation
300        );
301    }
302
303    #[test]
304    fn matching_boolean_values_pass() {
305        let validator = ConfirmationValidator::new("published_confirmation");
306        let attrs = HashMap::from([
307            (String::from("published"), json!(true)),
308            (String::from("published_confirmation"), json!(true)),
309        ]);
310
311        let errors = validate_confirmation(&validator, "published", attrs.get("published"), &attrs);
312
313        assert!(errors.is_empty());
314    }
315
316    #[test]
317    fn mismatched_boolean_values_fail() {
318        let validator = ConfirmationValidator::new("published_confirmation");
319        let attrs = HashMap::from([
320            (String::from("published"), json!(true)),
321            (String::from("published_confirmation"), json!(false)),
322        ]);
323
324        let errors = validate_confirmation(&validator, "published", attrs.get("published"), &attrs);
325
326        assert_eq!(
327            errors.on("published_confirmation")[0].error_type,
328            ErrorType::Confirmation
329        );
330    }
331
332    #[test]
333    fn direct_validate_is_a_no_op() {
334        let validator = ConfirmationValidator::new("password_confirmation");
335        let mut errors = Errors::new();
336
337        validator.validate("password", Some(&json!("secret")), &mut errors);
338
339        assert!(errors.is_empty());
340    }
341
342    #[test]
343    fn validation_set_passes_when_confirmation_matches() {
344        let mut set = ValidationSet::new();
345        set.add(
346            "password",
347            ConfirmationValidator::new("password_confirmation"),
348        );
349        let attrs = HashMap::from([
350            (String::from("password"), json!("secret")),
351            (String::from("password_confirmation"), json!("secret")),
352        ]);
353        let mut errors = Errors::new();
354
355        let _ = set.validate(&|name| attrs.get(name).cloned(), &mut errors);
356
357        assert!(errors.is_empty());
358    }
359
360    #[test]
361    fn full_message_humanizes_confirmation_attribute() {
362        let validator = ConfirmationValidator::new("password_confirmation");
363        let attrs = HashMap::from([
364            (String::from("password"), json!("secret")),
365            (String::from("password_confirmation"), json!("other")),
366        ]);
367
368        let errors = validate_confirmation(&validator, "password", attrs.get("password"), &attrs);
369
370        assert_eq!(
371            errors.full_messages(),
372            vec!["Password confirmation doesn't match Password".to_string()],
373        );
374    }
375}