1use std::collections::HashMap;
91
92use axum::extract::{FromRequest, Request};
93use axum::response::IntoResponse;
94use serde::Serialize;
95
96#[derive(Debug)]
107pub struct Changeset<T> {
108 data: T,
109 errors: HashMap<String, Vec<String>>,
110}
111
112impl<T> Changeset<T> {
113 pub fn new(data: T) -> Self {
115 Self {
116 data,
117 errors: HashMap::new(),
118 }
119 }
120
121 pub const fn from_errors(data: T, errors: HashMap<String, Vec<String>>) -> Self {
123 Self { data, errors }
124 }
125
126 pub fn is_valid(&self) -> bool {
128 self.errors.is_empty()
129 }
130
131 pub fn errors_for(&self, field: &str) -> &[String] {
133 self.errors.get(field).map_or(&[], Vec::as_slice)
134 }
135
136 pub fn into_inner(self) -> T {
138 self.data
139 }
140
141 pub fn into_valid(self) -> Result<T, Self> {
147 if self.is_valid() {
148 Ok(self.data)
149 } else {
150 Err(self)
151 }
152 }
153
154 pub const fn data(&self) -> &T {
156 &self.data
157 }
158
159 pub const fn errors(&self) -> &HashMap<String, Vec<String>> {
161 &self.errors
162 }
163}
164
165impl<T: Serialize> Changeset<T> {
166 pub fn field_value(&self, field: &str) -> Option<String> {
171 let json = serde_json::to_value(&self.data).ok()?;
172 match json.get(field)? {
173 serde_json::Value::String(s) => Some(s.clone()),
174 serde_json::Value::Number(n) => Some(n.to_string()),
175 serde_json::Value::Bool(b) => Some(b.to_string()),
176 _ => None,
177 }
178 }
179}
180
181pub trait IntoChangeset: Sized {
187 fn into_changeset(self) -> Changeset<Self>;
189}
190
191impl<T: validator::Validate> IntoChangeset for T {
192 fn into_changeset(self) -> Changeset<Self> {
193 match validator::Validate::validate(&self) {
194 Ok(()) => Changeset::new(self),
195 Err(errors) => Changeset::from_errors(self, validation_errors_to_map(&errors)),
196 }
197 }
198}
199
200pub struct ChangesetForm<T> {
239 pub changeset: Changeset<T>,
241 pub(crate) csrf_token: Option<String>,
242 pub(crate) csrf_field: String,
243}
244
245impl<T> ChangesetForm<T> {
246 pub fn blank(data: T, csrf_token: &str) -> Self {
259 Self {
260 changeset: Changeset::new(data),
261 csrf_token: Some(csrf_token.to_owned()),
262 csrf_field: "_csrf".to_owned(),
263 }
264 }
265
266 #[must_use]
273 pub fn without_csrf(data: T) -> Self {
274 Self {
275 changeset: Changeset::new(data),
276 csrf_token: None,
277 csrf_field: "_csrf".to_owned(),
278 }
279 }
280
281 #[must_use]
287 pub fn from_changeset(changeset: Changeset<T>) -> Self {
288 Self {
289 changeset,
290 csrf_token: None,
291 csrf_field: "_csrf".to_owned(),
292 }
293 }
294
295 #[must_use]
303 pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
304 self.csrf_field = field.into();
305 self
306 }
307
308 pub fn csrf_token(&self) -> Option<&str> {
310 self.csrf_token.as_deref()
311 }
312
313 pub fn into_changeset(self) -> Changeset<T> {
315 self.changeset
316 }
317
318 pub fn into_valid(self) -> Result<T, Self> {
328 if self.changeset.is_valid() {
329 Ok(self.changeset.into_inner())
330 } else {
331 Err(self)
332 }
333 }
334}
335
336impl<T> std::ops::Deref for ChangesetForm<T> {
340 type Target = Changeset<T>;
341 fn deref(&self) -> &Self::Target {
342 &self.changeset
343 }
344}
345
346#[cfg(feature = "maud")]
348impl<T: Serialize> ChangesetForm<T> {
349 #[must_use]
353 #[allow(clippy::needless_pass_by_value)]
354 pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
355 form_tag_inner(
356 action,
357 method,
358 &self.csrf_field,
359 self.csrf_token.as_deref(),
360 content,
361 )
362 }
363
364 pub fn text_input(&self, field: &str, label: &str) -> maud::Markup {
367 text_input(&self.changeset, field, label)
368 }
369
370 pub fn submit_button(&self, label: &str) -> maud::Markup {
372 submit_button(label)
373 }
374}
375
376impl<S, T> FromRequest<S> for ChangesetForm<T>
377where
378 S: Send + Sync,
379 T: serde::de::DeserializeOwned + validator::Validate,
380{
381 type Rejection = axum::response::Response;
382
383 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
384 let csrf_token = req
386 .extensions()
387 .get::<crate::security::CsrfToken>()
388 .map(|t| t.token().to_string());
389 let csrf_field = req
390 .extensions()
391 .get::<crate::security::csrf::CsrfFormField>()
392 .map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
393
394 let data: T = decode_form_body(req, state).await?;
395
396 Ok(Self {
397 changeset: data.into_changeset(),
398 csrf_token,
399 csrf_field,
400 })
401 }
402}
403
404async fn decode_form_body<T, S>(req: Request, state: &S) -> Result<T, axum::response::Response>
406where
407 T: serde::de::DeserializeOwned + validator::Validate,
408 S: Send + Sync,
409{
410 #[cfg(feature = "multipart")]
411 {
412 let content_type = req
413 .headers()
414 .get(http::header::CONTENT_TYPE)
415 .and_then(|v| v.to_str().ok())
416 .unwrap_or_default()
417 .to_string();
418 if content_type.starts_with("multipart/form-data") {
419 return decode_multipart(req, state).await;
420 }
421 }
422
423 let axum::extract::Form(data) = axum::extract::Form::<T>::from_request(req, state)
424 .await
425 .map_err(IntoResponse::into_response)?;
426 Ok(data)
427}
428
429#[cfg(feature = "multipart")]
435async fn decode_multipart<T, S>(req: Request, state: &S) -> Result<T, axum::response::Response>
436where
437 T: serde::de::DeserializeOwned,
438 S: Send + Sync,
439{
440 let mut multipart = axum::extract::Multipart::from_request(req, state)
441 .await
442 .map_err(IntoResponse::into_response)?;
443
444 let mut pairs: Vec<(String, String)> = Vec::new();
445
446 loop {
447 let field = multipart
448 .next_field()
449 .await
450 .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())?;
451
452 let Some(field) = field else { break };
453
454 let name = match field.name() {
455 Some(n) => n.to_string(),
456 None => continue,
457 };
458
459 if field.file_name().is_some() {
461 continue;
462 }
463
464 let value = field
465 .text()
466 .await
467 .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())?;
468
469 pairs.push((name, value));
470 }
471
472 let encoded = url::form_urlencoded::Serializer::new(String::new())
475 .extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
476 .finish();
477
478 serde_urlencoded::from_str::<T>(&encoded)
479 .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()).into_response())
480}
481
482fn validation_errors_to_map(errors: &validator::ValidationErrors) -> HashMap<String, Vec<String>> {
485 let mut map = HashMap::new();
486 collect_errors(errors, "", &mut map);
487 map
488}
489
490fn collect_errors(
491 errors: &validator::ValidationErrors,
492 prefix: &str,
493 map: &mut HashMap<String, Vec<String>>,
494) {
495 for (field, kind) in errors.errors() {
496 let key = if prefix.is_empty() {
497 (*field).to_string()
498 } else {
499 format!("{prefix}.{field}")
500 };
501 match kind {
502 validator::ValidationErrorsKind::Field(errs) => {
503 let messages: Vec<String> = errs
504 .iter()
505 .map(|e| {
506 e.message.as_ref().map_or_else(
507 || format!("validation failed: {}", e.code),
508 ToString::to_string,
509 )
510 })
511 .collect();
512 map.entry(key).or_default().extend(messages);
513 }
514 validator::ValidationErrorsKind::Struct(nested) => {
515 collect_errors(nested, &key, map);
516 }
517 validator::ValidationErrorsKind::List(list) => {
518 for (idx, nested) in list {
519 let indexed_key = format!("{key}[{idx}]");
520 collect_errors(nested, &indexed_key, map);
521 }
522 }
523 }
524 }
525}
526
527#[cfg(feature = "maud")]
541#[must_use]
542#[allow(clippy::needless_pass_by_value)]
543pub fn form_tag(
544 action: &str,
545 method: &str,
546 csrf_token: Option<&str>,
547 content: maud::Markup,
548) -> maud::Markup {
549 form_tag_inner(action, method, "_csrf", csrf_token, content)
550}
551
552#[cfg(feature = "maud")]
554#[allow(clippy::needless_pass_by_value)]
555fn form_tag_inner(
556 action: &str,
557 method: &str,
558 csrf_field: &str,
559 csrf_token: Option<&str>,
560 content: maud::Markup,
561) -> maud::Markup {
562 maud::html! {
563 form action=(action) method=(method) {
564 @if let Some(token) = csrf_token {
565 input type="hidden" name=(csrf_field) value=(token);
566 }
567 (content)
568 }
569 }
570}
571
572#[cfg(feature = "maud")]
579#[must_use]
580pub fn text_input<T: Serialize>(
581 changeset: &Changeset<T>,
582 field: &str,
583 label: &str,
584) -> maud::Markup {
585 let errors = changeset.errors_for(field);
586 let has_errors = !errors.is_empty();
587 let value = changeset.field_value(field).unwrap_or_default();
588 let error_id = format!("{field}-error");
589
590 maud::html! {
591 div {
592 label for=(field) { (label) }
593 input
594 type="text"
595 id=(field)
596 name=(field)
597 value=(value)
598 aria-invalid=(if has_errors { "true" } else { "false" })
599 aria-describedby=(if has_errors { error_id.as_str() } else { "" });
600 @if has_errors {
601 div id=(error_id) role="alert" {
602 @for error in errors {
603 p { (error) }
604 }
605 }
606 }
607 }
608 }
609}
610
611#[cfg(feature = "maud")]
613#[must_use]
614pub fn submit_button(label: &str) -> maud::Markup {
615 maud::html! {
616 button type="submit" { (label) }
617 }
618}
619
620#[cfg(feature = "maud")]
629#[must_use]
630pub fn password_input<T: Serialize>(
631 changeset: &Changeset<T>,
632 field: &str,
633 label: &str,
634) -> maud::Markup {
635 let errors = changeset.errors_for(field);
636 let has_errors = !errors.is_empty();
637 let error_id = format!("{field}-error");
638
639 maud::html! {
640 div {
641 label for=(field) { (label) }
642 input
643 type="password"
644 id=(field)
645 name=(field)
646 aria-invalid=(if has_errors { "true" } else { "false" })
647 aria-describedby=(if has_errors { error_id.as_str() } else { "" });
648 @if has_errors {
649 div id=(error_id) role="alert" {
650 @for error in errors {
651 p { (error) }
652 }
653 }
654 }
655 }
656 }
657}
658
659#[cfg(feature = "maud")]
664#[must_use]
665pub fn textarea_input<T: Serialize>(
666 changeset: &Changeset<T>,
667 field: &str,
668 label: &str,
669) -> maud::Markup {
670 let errors = changeset.errors_for(field);
671 let has_errors = !errors.is_empty();
672 let value = changeset.field_value(field).unwrap_or_default();
673 let error_id = format!("{field}-error");
674
675 maud::html! {
676 div {
677 label for=(field) { (label) }
678 textarea
679 id=(field)
680 name=(field)
681 aria-invalid=(if has_errors { "true" } else { "false" })
682 aria-describedby=(if has_errors { error_id.as_str() } else { "" })
683 { (value) }
684 @if has_errors {
685 div id=(error_id) role="alert" {
686 @for error in errors {
687 p { (error) }
688 }
689 }
690 }
691 }
692 }
693}
694
695#[cfg(feature = "maud")]
701#[must_use]
702pub fn required_text_input<T: Serialize>(
703 changeset: &Changeset<T>,
704 field: &str,
705 label: &str,
706) -> maud::Markup {
707 let errors = changeset.errors_for(field);
708 let has_errors = !errors.is_empty();
709 let value = changeset.field_value(field).unwrap_or_default();
710 let error_id = format!("{field}-error");
711
712 maud::html! {
713 div {
714 label for=(field) { (label) }
715 input
716 type="text"
717 id=(field)
718 name=(field)
719 value=(value)
720 required
721 aria-required="true"
722 aria-invalid=(if has_errors { "true" } else { "false" })
723 aria-describedby=(if has_errors { error_id.as_str() } else { "" });
724 @if has_errors {
725 div id=(error_id) role="alert" {
726 @for error in errors {
727 p { (error) }
728 }
729 }
730 }
731 }
732 }
733}
734
735#[cfg(feature = "maud")]
755#[must_use]
756pub fn aria_live_region(id: &str, message: &str) -> maud::Markup {
757 maud::html! {
758 div id=(id) role="status" aria-live="polite" aria-atomic="true" {
759 (message)
760 }
761 }
762}
763
764#[cfg(feature = "maud")]
777#[must_use]
778pub fn skip_link(target: &str, label: &str) -> maud::Markup {
779 maud::html! {
780 a href=(target) class="skip-link" { (label) }
781 }
782}
783
784#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
793 fn new_changeset_is_valid() {
794 let cs = Changeset::new(42_i32);
795 assert!(cs.is_valid());
796 }
797
798 #[test]
799 fn new_changeset_has_no_errors() {
800 let cs = Changeset::new("hello");
801 assert!(cs.errors().is_empty());
802 }
803
804 #[test]
805 fn new_changeset_into_inner() {
806 let cs = Changeset::new(99_u8);
807 assert_eq!(cs.into_inner(), 99);
808 }
809
810 #[test]
811 fn new_changeset_data_ref() {
812 let cs = Changeset::new(vec![1, 2, 3]);
813 assert_eq!(cs.data(), &vec![1, 2, 3]);
814 }
815
816 #[test]
819 fn from_errors_changeset_is_invalid() {
820 let mut errors = HashMap::new();
821 errors.insert("name".to_string(), vec!["too short".to_string()]);
822 let cs = Changeset::from_errors("data", errors);
823 assert!(!cs.is_valid());
824 }
825
826 #[test]
827 fn from_errors_returns_correct_field_errors() {
828 let mut errors = HashMap::new();
829 errors.insert("email".to_string(), vec!["invalid email".to_string()]);
830 let cs = Changeset::from_errors("data", errors);
831 assert_eq!(cs.errors_for("email"), &["invalid email"]);
832 }
833
834 #[test]
835 fn errors_for_unknown_field_returns_empty_slice() {
836 let cs = Changeset::new("data");
837 assert!(cs.errors_for("nonexistent").is_empty());
838 }
839
840 #[test]
841 fn from_errors_multiple_messages_per_field() {
842 let mut errors = HashMap::new();
843 errors.insert(
844 "password".to_string(),
845 vec!["too short".to_string(), "must contain a digit".to_string()],
846 );
847 let cs = Changeset::from_errors("data", errors);
848 let msgs = cs.errors_for("password");
849 assert_eq!(msgs.len(), 2);
850 assert!(msgs.contains(&"too short".to_string()));
851 assert!(msgs.contains(&"must contain a digit".to_string()));
852 }
853
854 #[test]
857 fn into_valid_returns_ok_when_valid() {
858 let cs = Changeset::new(42_i32);
859 assert_eq!(cs.into_valid().unwrap(), 42);
860 }
861
862 #[test]
863 fn into_valid_returns_err_when_invalid() {
864 let mut errors = HashMap::new();
865 errors.insert("x".to_string(), vec!["err".to_string()]);
866 let cs = Changeset::from_errors(42_i32, errors);
867 assert!(cs.into_valid().is_err());
868 }
869
870 #[test]
871 fn into_valid_err_preserves_changeset() {
872 let mut errors = HashMap::new();
873 errors.insert("name".to_string(), vec!["required".to_string()]);
874 let cs = Changeset::from_errors(7_i32, errors);
875 let err_cs = cs.into_valid().unwrap_err();
876 assert_eq!(err_cs.into_inner(), 7);
877 }
878
879 #[test]
882 fn field_value_returns_string_field() {
883 #[derive(serde::Serialize)]
884 struct Form {
885 name: String,
886 }
887 let cs = Changeset::new(Form {
888 name: "Alice".into(),
889 });
890 assert_eq!(cs.field_value("name"), Some("Alice".to_string()));
891 }
892
893 #[test]
894 fn field_value_returns_number_as_string() {
895 #[derive(serde::Serialize)]
896 struct Form {
897 age: u32,
898 }
899 let cs = Changeset::new(Form { age: 30 });
900 assert_eq!(cs.field_value("age"), Some("30".to_string()));
901 }
902
903 #[test]
904 fn field_value_returns_bool_as_string() {
905 #[derive(serde::Serialize)]
906 struct Form {
907 active: bool,
908 }
909 let cs = Changeset::new(Form { active: true });
910 assert_eq!(cs.field_value("active"), Some("true".to_string()));
911 }
912
913 #[test]
914 fn field_value_returns_none_for_missing_field() {
915 #[derive(serde::Serialize)]
916 struct Form {
917 name: String,
918 }
919 let cs = Changeset::new(Form {
920 name: "Alice".into(),
921 });
922 assert_eq!(cs.field_value("email"), None);
923 }
924
925 #[test]
926 fn field_value_after_errors_uses_submitted_data() {
927 #[derive(serde::Serialize)]
928 struct Form {
929 name: String,
930 }
931 let mut errors = HashMap::new();
932 errors.insert("name".to_string(), vec!["too short".to_string()]);
933 let cs = Changeset::from_errors(Form { name: "ab".into() }, errors);
934 assert_eq!(cs.field_value("name"), Some("ab".to_string()));
935 }
936
937 #[test]
940 fn into_changeset_valid_input_produces_no_errors() {
941 #[derive(validator::Validate)]
942 struct F {
943 #[validate(length(min = 3))]
944 name: String,
945 }
946 let cs = F {
947 name: "Alice".into(),
948 }
949 .into_changeset();
950 assert!(cs.is_valid());
951 assert!(cs.errors_for("name").is_empty());
952 }
953
954 #[test]
955 fn into_changeset_invalid_input_populates_errors() {
956 #[derive(validator::Validate)]
957 struct F {
958 #[validate(length(min = 5))]
959 name: String,
960 }
961 let cs = F { name: "ab".into() }.into_changeset();
962 assert!(!cs.is_valid());
963 assert!(!cs.errors_for("name").is_empty());
964 }
965
966 #[test]
967 fn into_changeset_preserves_data_on_failure() {
968 #[derive(validator::Validate)]
969 struct F {
970 #[validate(length(min = 5))]
971 name: String,
972 }
973 let cs = F { name: "ab".into() }.into_changeset();
974 assert_eq!(cs.data().name, "ab");
975 }
976
977 #[test]
978 fn into_changeset_multiple_fields_errors() {
979 #[derive(validator::Validate)]
980 struct F {
981 #[validate(length(min = 3))]
982 name: String,
983 #[validate(email)]
984 email: String,
985 }
986 let cs = F {
987 name: "a".into(),
988 email: "not-email".into(),
989 }
990 .into_changeset();
991 assert!(!cs.is_valid());
992 assert!(!cs.errors_for("name").is_empty());
993 assert!(!cs.errors_for("email").is_empty());
994 }
995
996 mod nested_validation {
997 use super::*;
998 use validator::Validate as _;
999
1000 #[derive(validator::Validate)]
1001 struct NestedAddress {
1002 #[validate(length(min = 3, message = "street too short"))]
1003 street: String,
1004 }
1005
1006 #[derive(validator::Validate)]
1007 struct PersonWithAddress {
1008 #[validate(nested)]
1009 address: NestedAddress,
1010 }
1011
1012 #[test]
1013 fn nested_struct_errors_are_flattened_with_dot_notation() {
1014 let cs = PersonWithAddress {
1015 address: NestedAddress { street: "x".into() },
1016 }
1017 .into_changeset();
1018 assert!(!cs.is_valid());
1019 assert!(!cs.errors_for("address.street").is_empty());
1020 }
1021 }
1022
1023 #[test]
1026 fn changeset_form_blank_is_valid() {
1027 #[derive(validator::Validate, serde::Serialize)]
1028 struct F {
1029 #[validate(length(min = 1))]
1030 name: String,
1031 }
1032 let form = ChangesetForm::blank(F { name: "ok".into() }, "tok");
1033 assert!(form.is_valid()); assert_eq!(form.csrf_token(), Some("tok"));
1035 }
1036
1037 #[test]
1038 fn changeset_form_deref_exposes_changeset_methods() {
1039 #[derive(validator::Validate)]
1040 struct F {
1041 #[validate(length(min = 3))]
1042 name: String,
1043 }
1044 let changeset = F { name: "ab".into() }.into_changeset();
1045 let form = ChangesetForm {
1046 changeset,
1047 csrf_token: None,
1048 csrf_field: "_csrf".into(),
1049 };
1050 assert!(!form.is_valid());
1052 assert!(!form.errors_for("name").is_empty());
1053 }
1054
1055 #[test]
1056 fn changeset_form_into_valid_ok() {
1057 #[derive(validator::Validate)]
1058 struct F {
1059 #[validate(length(min = 1))]
1060 name: String,
1061 }
1062 let form = ChangesetForm {
1063 changeset: F { name: "ok".into() }.into_changeset(),
1064 csrf_token: None,
1065 csrf_field: "_csrf".into(),
1066 };
1067 assert!(form.into_valid().is_ok());
1068 }
1069
1070 #[test]
1071 fn changeset_form_into_valid_err_preserves_csrf() {
1072 #[derive(Debug, validator::Validate)]
1073 struct F {
1074 #[validate(length(min = 5))]
1075 name: String,
1076 }
1077 let form = ChangesetForm {
1078 changeset: F { name: "ab".into() }.into_changeset(),
1079 csrf_token: Some("tok123".into()),
1080 csrf_field: "_csrf".into(),
1081 };
1082 let err_form = form.into_valid().unwrap_err();
1083 assert_eq!(err_form.csrf_token(), Some("tok123"));
1084 }
1085
1086 #[cfg(feature = "maud")]
1089 #[test]
1090 fn form_tag_renders_action_and_method() {
1091 let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
1092 assert!(html.contains(r#"action="/users""#), "{html}");
1093 assert!(html.contains(r#"method="post""#), "{html}");
1094 }
1095
1096 #[cfg(feature = "maud")]
1097 #[test]
1098 fn form_tag_emits_csrf_hidden_input_when_token_provided() {
1099 let html = form_tag("/users", "post", Some("tok123"), maud::html! { "" }).into_string();
1100 assert!(html.contains(r#"name="_csrf""#), "{html}");
1101 assert!(html.contains(r#"value="tok123""#), "{html}");
1102 assert!(html.contains(r#"type="hidden""#), "{html}");
1103 }
1104
1105 #[cfg(feature = "maud")]
1106 #[test]
1107 fn form_tag_omits_csrf_input_when_none() {
1108 let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
1109 assert!(!html.contains("_csrf"), "{html}");
1110 }
1111
1112 #[cfg(feature = "maud")]
1113 #[test]
1114 fn form_tag_includes_content() {
1115 let html = form_tag("/x", "post", None, maud::html! { span { "inner" } }).into_string();
1116 assert!(html.contains("inner"), "{html}");
1117 }
1118
1119 #[cfg(feature = "maud")]
1120 #[test]
1121 fn changeset_form_form_tag_injects_stored_csrf() {
1122 #[derive(validator::Validate, serde::Serialize)]
1123 struct F {
1124 name: String,
1125 }
1126 let form = ChangesetForm::blank(
1127 F {
1128 name: String::new(),
1129 },
1130 "secret-token",
1131 );
1132 let html = form
1133 .form_tag("/x", "post", maud::html! { "" })
1134 .into_string();
1135 assert!(html.contains(r#"value="secret-token""#), "{html}");
1136 assert!(html.contains(r#"name="_csrf""#), "{html}");
1137 }
1138
1139 #[cfg(feature = "maud")]
1140 #[test]
1141 fn changeset_form_form_tag_honours_custom_csrf_field_name() {
1142 #[derive(validator::Validate, serde::Serialize)]
1143 struct F {
1144 name: String,
1145 }
1146 let form = ChangesetForm {
1147 changeset: Changeset::new(F {
1148 name: String::new(),
1149 }),
1150 csrf_token: Some("tok".into()),
1151 csrf_field: "authenticity_token".into(),
1152 };
1153 let html = form
1154 .form_tag("/x", "post", maud::html! { "" })
1155 .into_string();
1156 assert!(html.contains(r#"name="authenticity_token""#), "{html}");
1157 assert!(!html.contains(r#"name="_csrf""#), "{html}");
1158 }
1159
1160 #[cfg(feature = "maud")]
1161 #[test]
1162 fn text_input_renders_label_name_and_value() {
1163 #[derive(serde::Serialize)]
1164 struct F {
1165 name: String,
1166 }
1167 let cs = Changeset::new(F {
1168 name: "Alice".into(),
1169 });
1170 let html = text_input(&cs, "name", "Full Name").into_string();
1171 assert!(html.contains(r#"name="name""#), "{html}");
1172 assert!(html.contains(r#"value="Alice""#), "{html}");
1173 assert!(html.contains("Full Name"), "{html}");
1174 }
1175
1176 #[cfg(feature = "maud")]
1177 #[test]
1178 fn text_input_aria_invalid_false_when_no_errors() {
1179 #[derive(serde::Serialize)]
1180 struct F {
1181 name: String,
1182 }
1183 let cs = Changeset::new(F {
1184 name: "Alice".into(),
1185 });
1186 let html = text_input(&cs, "name", "Name").into_string();
1187 assert!(html.contains(r#"aria-invalid="false""#), "{html}");
1188 assert!(!html.contains(r#"role="alert""#), "{html}");
1189 }
1190
1191 #[cfg(feature = "maud")]
1192 #[test]
1193 fn text_input_aria_invalid_true_and_error_block_on_failure() {
1194 #[derive(serde::Serialize)]
1195 struct F {
1196 name: String,
1197 }
1198 let mut errors = HashMap::new();
1199 errors.insert("name".to_string(), vec!["too short".to_string()]);
1200 let cs = Changeset::from_errors(F { name: "ab".into() }, errors);
1201 let html = text_input(&cs, "name", "Name").into_string();
1202 assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1203 assert!(html.contains(r#"role="alert""#), "{html}");
1204 assert!(html.contains("too short"), "{html}");
1205 }
1206
1207 #[cfg(feature = "maud")]
1208 #[test]
1209 fn text_input_error_block_has_describedby_link() {
1210 #[derive(serde::Serialize)]
1211 struct F {
1212 email: String,
1213 }
1214 let mut errors = HashMap::new();
1215 errors.insert("email".to_string(), vec!["invalid".to_string()]);
1216 let cs = Changeset::from_errors(F { email: "x".into() }, errors);
1217 let html = text_input(&cs, "email", "Email").into_string();
1218 assert!(html.contains("email-error"), "{html}");
1219 assert!(html.contains(r#"aria-describedby="email-error""#), "{html}");
1220 }
1221
1222 #[cfg(feature = "maud")]
1223 #[test]
1224 fn text_input_multiple_errors_all_rendered() {
1225 #[derive(serde::Serialize)]
1226 struct F {
1227 password: String,
1228 }
1229 let mut errors = HashMap::new();
1230 errors.insert(
1231 "password".to_string(),
1232 vec!["too short".to_string(), "needs digit".to_string()],
1233 );
1234 let cs = Changeset::from_errors(
1235 F {
1236 password: "x".into(),
1237 },
1238 errors,
1239 );
1240 let html = text_input(&cs, "password", "Password").into_string();
1241 assert!(html.contains("too short"), "{html}");
1242 assert!(html.contains("needs digit"), "{html}");
1243 }
1244
1245 #[cfg(feature = "maud")]
1246 #[test]
1247 fn submit_button_renders_button_with_label() {
1248 let html = submit_button("Save").into_string();
1249 assert!(html.contains(r#"type="submit""#), "{html}");
1250 assert!(html.contains("Save"), "{html}");
1251 }
1252
1253 #[cfg(feature = "maud")]
1256 #[test]
1257 fn password_input_renders_type_password() {
1258 #[derive(serde::Serialize)]
1259 struct F {
1260 password: String,
1261 }
1262 let cs = Changeset::new(F {
1263 password: String::new(),
1264 });
1265 let html = password_input(&cs, "password", "Password").into_string();
1266 assert!(html.contains(r#"type="password""#), "{html}");
1267 assert!(html.contains(r#"name="password""#), "{html}");
1268 assert!(html.contains("Password"), "{html}");
1269 assert!(!html.contains(r#"value=""#), "{html}");
1271 }
1272
1273 #[cfg(feature = "maud")]
1274 #[test]
1275 fn password_input_emits_aria_invalid_on_error() {
1276 #[derive(serde::Serialize)]
1277 struct F {
1278 password: String,
1279 }
1280 let mut errors = HashMap::new();
1281 errors.insert("password".to_string(), vec!["too short".to_string()]);
1282 let cs = Changeset::from_errors(
1283 F {
1284 password: "x".into(),
1285 },
1286 errors,
1287 );
1288 let html = password_input(&cs, "password", "Password").into_string();
1289 assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1290 assert!(html.contains(r#"role="alert""#), "{html}");
1291 assert!(html.contains("too short"), "{html}");
1292 }
1293
1294 #[cfg(feature = "maud")]
1295 #[test]
1296 fn textarea_input_renders_textarea_element() {
1297 #[derive(serde::Serialize)]
1298 struct F {
1299 bio: String,
1300 }
1301 let cs = Changeset::new(F {
1302 bio: "Hello world".into(),
1303 });
1304 let html = textarea_input(&cs, "bio", "Bio").into_string();
1305 assert!(html.contains("<textarea"), "{html}");
1306 assert!(html.contains(r#"name="bio""#), "{html}");
1307 assert!(html.contains(r#"id="bio""#), "{html}");
1308 assert!(html.contains("Bio"), "{html}");
1309 assert!(html.contains("Hello world"), "{html}");
1310 }
1311
1312 #[cfg(feature = "maud")]
1313 #[test]
1314 fn textarea_input_aria_invalid_on_error() {
1315 #[derive(serde::Serialize)]
1316 struct F {
1317 bio: String,
1318 }
1319 let mut errors = HashMap::new();
1320 errors.insert("bio".to_string(), vec!["required".to_string()]);
1321 let cs = Changeset::from_errors(F { bio: String::new() }, errors);
1322 let html = textarea_input(&cs, "bio", "Bio").into_string();
1323 assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1324 assert!(html.contains(r#"role="alert""#), "{html}");
1325 assert!(html.contains("required"), "{html}");
1326 }
1327
1328 #[cfg(feature = "maud")]
1329 #[test]
1330 fn required_text_input_emits_aria_required() {
1331 #[derive(serde::Serialize)]
1332 struct F {
1333 name: String,
1334 }
1335 let cs = Changeset::new(F {
1336 name: "Alice".into(),
1337 });
1338 let html = required_text_input(&cs, "name", "Name").into_string();
1339 assert!(html.contains(r#"aria-required="true""#), "{html}");
1340 assert!(html.contains("required"), "{html}");
1341 assert!(html.contains(r#"name="name""#), "{html}");
1342 assert!(html.contains("Name"), "{html}");
1343 }
1344
1345 #[cfg(feature = "maud")]
1346 #[test]
1347 fn required_text_input_preserves_error_handling() {
1348 #[derive(serde::Serialize)]
1349 struct F {
1350 name: String,
1351 }
1352 let mut errors = HashMap::new();
1353 errors.insert("name".to_string(), vec!["required".to_string()]);
1354 let cs = Changeset::from_errors(
1355 F {
1356 name: String::new(),
1357 },
1358 errors,
1359 );
1360 let html = required_text_input(&cs, "name", "Name").into_string();
1361 assert!(html.contains(r#"aria-invalid="true""#), "{html}");
1362 assert!(html.contains(r#"aria-required="true""#), "{html}");
1363 assert!(html.contains(r#"role="alert""#), "{html}");
1364 }
1365
1366 #[cfg(feature = "maud")]
1367 #[test]
1368 fn aria_live_region_renders_role_status() {
1369 let html = aria_live_region("status-msg", "").into_string();
1370 assert!(html.contains(r#"role="status""#), "{html}");
1371 assert!(html.contains(r#"aria-live="polite""#), "{html}");
1372 assert!(html.contains(r#"id="status-msg""#), "{html}");
1373 }
1374
1375 #[cfg(feature = "maud")]
1376 #[test]
1377 fn aria_live_region_renders_message_content() {
1378 let html = aria_live_region("status-msg", "Form submitted").into_string();
1379 assert!(html.contains("Form submitted"), "{html}");
1380 }
1381
1382 #[cfg(feature = "maud")]
1383 #[test]
1384 fn skip_link_renders_anchor_with_href() {
1385 let html = skip_link("#main-content", "Skip to main content").into_string();
1386 assert!(html.contains(r##"href="#main-content""##), "{html}");
1387 assert!(html.contains("Skip to main content"), "{html}");
1388 }
1389
1390 #[cfg(feature = "maud")]
1391 #[test]
1392 fn skip_link_has_visually_hidden_class_for_focus_reveal() {
1393 let html = skip_link("#main", "Skip").into_string();
1394 assert!(html.contains("skip-link"), "{html}");
1395 }
1396
1397 mod extractor_tests {
1400 use super::*;
1401 use axum::{Router, body::Body, routing::post};
1402 use tower::ServiceExt;
1403
1404 #[derive(serde::Deserialize, validator::Validate)]
1405 struct TestForm {
1406 #[validate(length(min = 3))]
1407 name: String,
1408 }
1409
1410 #[tokio::test]
1411 async fn valid_form_body_produces_valid_changeset() {
1412 async fn handler(form: ChangesetForm<TestForm>) -> String {
1413 format!("valid={}", form.is_valid())
1414 }
1415 let resp = Router::new()
1416 .route("/test", post(handler))
1417 .oneshot(urlencoded_req("/test", "name=Alice"))
1418 .await
1419 .unwrap();
1420 assert_body(resp, "valid=true").await;
1421 }
1422
1423 #[tokio::test]
1424 async fn invalid_form_body_produces_invalid_changeset() {
1425 async fn handler(form: ChangesetForm<TestForm>) -> String {
1426 format!("valid={}", form.is_valid())
1427 }
1428 let resp = Router::new()
1429 .route("/test", post(handler))
1430 .oneshot(urlencoded_req("/test", "name=ab"))
1431 .await
1432 .unwrap();
1433 assert_body(resp, "valid=false").await;
1434 }
1435
1436 #[tokio::test]
1437 async fn invalid_form_exposes_field_errors() {
1438 async fn handler(form: ChangesetForm<TestForm>) -> String {
1439 form.errors_for("name").join("|")
1440 }
1441 let resp = Router::new()
1442 .route("/test", post(handler))
1443 .oneshot(urlencoded_req("/test", "name=ab"))
1444 .await
1445 .unwrap();
1446 let body = body_text(resp).await;
1447 assert!(!body.is_empty(), "expected errors, got empty string");
1448 }
1449
1450 #[tokio::test]
1451 async fn missing_required_field_returns_non_200() {
1452 async fn handler(form: ChangesetForm<TestForm>) -> String {
1453 format!("valid={}", form.is_valid())
1454 }
1455 let resp = Router::new()
1456 .route("/test", post(handler))
1457 .oneshot(urlencoded_req("/test", "other=value"))
1458 .await
1459 .unwrap();
1460 assert_ne!(resp.status(), axum::http::StatusCode::OK);
1461 }
1462
1463 #[tokio::test]
1464 async fn csrf_token_is_none_without_csrf_middleware() {
1465 async fn handler(form: ChangesetForm<TestForm>) -> String {
1466 form.csrf_token().unwrap_or("none").to_string()
1467 }
1468 let resp = Router::new()
1469 .route("/test", post(handler))
1470 .oneshot(urlencoded_req("/test", "name=Alice"))
1471 .await
1472 .unwrap();
1473 assert_body(resp, "none").await;
1474 }
1475
1476 #[tokio::test]
1477 async fn csrf_token_captured_from_request_extensions() {
1478 use crate::security::CsrfToken;
1481
1482 let mut req = axum::http::Request::builder()
1483 .method("POST")
1484 .uri("/test")
1485 .header("Content-Type", "application/x-www-form-urlencoded")
1486 .body(Body::from("name=Alice"))
1487 .unwrap();
1488 req.extensions_mut()
1489 .insert(CsrfToken::new("secret-tok".to_string()));
1490
1491 let form = ChangesetForm::<TestForm>::from_request(req, &())
1492 .await
1493 .expect("extraction should succeed");
1494
1495 assert_eq!(form.csrf_token(), Some("secret-tok"));
1496 }
1497
1498 #[cfg(feature = "multipart")]
1499 #[tokio::test]
1500 async fn multipart_form_decodes_text_fields() {
1501 async fn handler(form: ChangesetForm<TestForm>) -> String {
1502 format!("valid={} name={}", form.is_valid(), form.data().name)
1503 }
1504 let resp = Router::new()
1505 .route("/test", post(handler))
1506 .oneshot(multipart_req("/test", "name", "Alice"))
1507 .await
1508 .unwrap();
1509 assert_body(resp, "valid=true name=Alice").await;
1510 }
1511
1512 #[cfg(feature = "multipart")]
1513 #[tokio::test]
1514 async fn multipart_form_validates_fields() {
1515 async fn handler(form: ChangesetForm<TestForm>) -> String {
1516 format!("valid={}", form.is_valid())
1517 }
1518 let resp = Router::new()
1519 .route("/test", post(handler))
1520 .oneshot(multipart_req("/test", "name", "ab"))
1521 .await
1522 .unwrap();
1523 assert_body(resp, "valid=false").await;
1524 }
1525
1526 fn urlencoded_req(uri: &str, body: &'static str) -> axum::http::Request<Body> {
1529 axum::http::Request::builder()
1530 .method("POST")
1531 .uri(uri)
1532 .header("Content-Type", "application/x-www-form-urlencoded")
1533 .body(Body::from(body))
1534 .unwrap()
1535 }
1536
1537 #[cfg(feature = "multipart")]
1538 fn multipart_req(uri: &str, field: &str, value: &str) -> axum::http::Request<Body> {
1539 let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
1540 let body = format!(
1541 "--{boundary}\r\n\
1542 Content-Disposition: form-data; name=\"{field}\"\r\n\r\n\
1543 {value}\r\n\
1544 --{boundary}--\r\n"
1545 );
1546 axum::http::Request::builder()
1547 .method("POST")
1548 .uri(uri)
1549 .header(
1550 "Content-Type",
1551 format!("multipart/form-data; boundary={boundary}"),
1552 )
1553 .body(Body::from(body))
1554 .unwrap()
1555 }
1556
1557 async fn body_text(resp: axum::response::Response) -> String {
1558 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1559 .await
1560 .unwrap();
1561 String::from_utf8(bytes.to_vec()).unwrap()
1562 }
1563
1564 async fn assert_body(resp: axum::response::Response, expected: &str) {
1565 assert_eq!(body_text(resp).await, expected);
1566 }
1567 }
1568}