1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct Location {
13 pub file: String,
15 pub line: usize,
17 pub column: usize,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct HttpTransportConfig {
24 pub proxy: Option<String>,
25 pub no_proxy: Option<String>,
26 pub cacert: Option<String>,
27 pub cert: Option<String>,
28 pub key: Option<String>,
29 pub insecure: bool,
30 pub http_version: Option<HttpVersionPreference>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HttpVersionPreference {
35 Http1_1,
36 Http2,
37}
38
39impl HttpTransportConfig {
40 pub fn merge(project: &Self, cli: &Self) -> Self {
42 Self {
43 proxy: cli.proxy.clone().or_else(|| project.proxy.clone()),
44 no_proxy: cli.no_proxy.clone().or_else(|| project.no_proxy.clone()),
45 cacert: cli.cacert.clone().or_else(|| project.cacert.clone()),
46 cert: cli.cert.clone().or_else(|| project.cert.clone()),
47 key: cli.key.clone().or_else(|| project.key.clone()),
48 insecure: cli.insecure || project.insecure,
49 http_version: cli.http_version.or(project.http_version),
50 }
51 }
52
53 pub fn has_custom_transport(&self) -> bool {
54 self.proxy.is_some()
55 || self.no_proxy.is_some()
56 || self.cacert.is_some()
57 || self.cert.is_some()
58 || self.key.is_some()
59 || self.insecure
60 || self.http_version.is_some()
61 }
62}
63
64#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
65pub struct RedactionConfig {
66 #[serde(default = "default_redacted_headers")]
67 pub headers: Vec<String>,
68 #[serde(default = "default_redaction_replacement")]
69 pub replacement: String,
70 #[serde(default, rename = "env")]
71 pub env_vars: Vec<String>,
72 #[serde(default)]
73 pub captures: Vec<String>,
74}
75
76impl Default for RedactionConfig {
77 fn default() -> Self {
78 Self {
79 headers: default_redacted_headers(),
80 replacement: default_redaction_replacement(),
81 env_vars: Vec::new(),
82 captures: Vec::new(),
83 }
84 }
85}
86
87impl RedactionConfig {
88 pub fn merge_headers<I, S>(&mut self, extra: I)
98 where
99 I: IntoIterator<Item = S>,
100 S: AsRef<str>,
101 {
102 for name in extra {
103 let trimmed = name.as_ref().trim();
104 if trimmed.is_empty() {
105 continue;
106 }
107 let normalized = trimmed.to_ascii_lowercase();
108 if !self
109 .headers
110 .iter()
111 .any(|existing| existing.eq_ignore_ascii_case(&normalized))
112 {
113 self.headers.push(normalized);
114 }
115 }
116 }
117}
118
119fn default_redacted_headers() -> Vec<String> {
120 vec![
121 "authorization".into(),
122 "cookie".into(),
123 "set-cookie".into(),
124 "x-api-key".into(),
125 "x-auth-token".into(),
126 ]
127}
128
129fn default_redaction_replacement() -> String {
130 "***".into()
131}
132
133#[derive(Debug, Deserialize, Clone)]
139pub struct TestFile {
140 pub version: Option<String>,
142
143 pub name: String,
145
146 pub description: Option<String>,
148
149 #[serde(default)]
151 pub tags: Vec<String>,
152
153 #[serde(default)]
157 pub openapi_operation_ids: Option<Vec<String>>,
158
159 #[serde(default)]
161 pub env: HashMap<String, String>,
162
163 #[serde(alias = "redact")]
165 pub redaction: Option<RedactionConfig>,
166
167 pub defaults: Option<Defaults>,
169
170 #[serde(default)]
172 pub setup: Vec<Step>,
173
174 #[serde(default)]
176 pub teardown: Vec<Step>,
177
178 #[serde(default)]
180 pub tests: IndexMap<String, TestGroup>,
181
182 #[serde(default)]
184 pub steps: Vec<Step>,
185
186 #[serde(default)]
188 pub cookies: Option<CookieMode>,
189
190 #[serde(default)]
196 pub serial_only: bool,
197
198 #[serde(default)]
205 pub group: Option<String>,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
216pub enum CookieMode {
217 #[default]
218 Auto,
219 Off,
220 PerTest,
221}
222
223impl<'de> Deserialize<'de> for CookieMode {
224 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
225 where
226 D: serde::Deserializer<'de>,
227 {
228 let value = String::deserialize(deserializer)?;
229 match value.as_str() {
230 "auto" => Ok(CookieMode::Auto),
231 "off" => Ok(CookieMode::Off),
232 "per-test" => Ok(CookieMode::PerTest),
233 other => Err(serde::de::Error::custom(format!(
234 "cookies must be \"auto\", \"off\", or \"per-test\" (got \"{}\")",
235 other
236 ))),
237 }
238 }
239}
240
241#[derive(Debug, Deserialize, Clone)]
243pub struct TestGroup {
244 pub description: Option<String>,
245
246 #[serde(default)]
247 pub tags: Vec<String>,
248
249 #[serde(default)]
250 pub steps: Vec<Step>,
251
252 #[serde(default)]
259 pub serial_only: bool,
260}
261
262#[derive(Debug, Deserialize, Clone)]
264pub struct Step {
265 pub name: String,
266
267 pub description: Option<String>,
272
273 pub request: Request,
274
275 #[serde(default)]
277 pub capture: HashMap<String, CaptureSpec>,
278
279 #[serde(rename = "assert")]
281 pub assertions: Option<Assertion>,
282
283 #[serde(default, rename = "if")]
287 pub run_if: Option<String>,
288
289 pub unless: Option<String>,
292
293 #[serde(default)]
295 pub retries: Option<u32>,
296
297 pub timeout: Option<u64>,
299
300 #[serde(alias = "connect-timeout")]
302 pub connect_timeout: Option<u64>,
303
304 #[serde(alias = "follow-redirects")]
306 pub follow_redirects: Option<bool>,
307
308 #[serde(alias = "max-redirs")]
310 pub max_redirs: Option<u32>,
311
312 pub delay: Option<String>,
314
315 pub poll: Option<PollConfig>,
317
318 pub script: Option<String>,
320
321 pub cookies: Option<StepCookies>,
326
327 #[serde(default)]
333 pub debug: bool,
334
335 #[serde(skip)]
339 pub location: Option<Location>,
340
341 #[serde(skip)]
346 pub assertion_locations: HashMap<String, Location>,
347}
348
349#[derive(Debug, Clone, PartialEq)]
351pub enum StepCookies {
352 Enabled(bool),
354 Named(String),
356}
357
358impl<'de> Deserialize<'de> for StepCookies {
359 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
360 where
361 D: serde::Deserializer<'de>,
362 {
363 let value = serde_yaml::Value::deserialize(deserializer)?;
364 match value {
365 serde_yaml::Value::Bool(b) => Ok(StepCookies::Enabled(b)),
366 serde_yaml::Value::String(s) => Ok(StepCookies::Named(s)),
367 _ => Err(serde::de::Error::custom(
368 "cookies must be true, false, or a jar name string",
369 )),
370 }
371 }
372}
373
374#[derive(Debug, Deserialize, Clone)]
384#[serde(untagged)]
385pub enum CaptureSpec {
386 JsonPath(String),
388 Extended(Box<ExtendedCapture>),
390}
391
392#[derive(Debug, Deserialize, Clone, Default)]
394pub struct ExtendedCapture {
395 pub header: Option<String>,
397 pub cookie: Option<String>,
399 pub jsonpath: Option<String>,
401 pub body: Option<bool>,
403 pub status: Option<bool>,
405 pub url: Option<bool>,
407 pub regex: Option<String>,
409 #[serde(default, rename = "where")]
416 pub where_predicate: Option<serde_yaml::Value>,
417 #[serde(default)]
424 pub optional: Option<bool>,
425 #[serde(default, deserialize_with = "deserialize_default_value")]
435 pub default: Option<DefaultValue>,
436 #[serde(default)]
440 pub when: Option<CaptureWhen>,
441}
442
443#[derive(Debug, Deserialize, Clone)]
449pub struct CaptureWhen {
450 pub status: Option<StatusAssertion>,
454}
455
456#[derive(Debug, Clone, PartialEq)]
462pub struct DefaultValue(pub serde_yaml::Value);
463
464impl<'de> Deserialize<'de> for DefaultValue {
465 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
466 where
467 D: serde::Deserializer<'de>,
468 {
469 let value = serde_yaml::Value::deserialize(deserializer)?;
470 Ok(DefaultValue(value))
471 }
472}
473
474impl DefaultValue {
475 pub fn as_value(&self) -> &serde_yaml::Value {
478 &self.0
479 }
480}
481
482fn deserialize_default_value<'de, D>(deserializer: D) -> Result<Option<DefaultValue>, D::Error>
488where
489 D: serde::Deserializer<'de>,
490{
491 let value = serde_yaml::Value::deserialize(deserializer)?;
496 Ok(Some(DefaultValue(value)))
497}
498
499#[derive(Debug, Deserialize, Clone)]
501pub struct PollConfig {
502 pub until: Assertion,
504 pub interval: String,
506 pub max_attempts: u32,
508}
509
510#[derive(Debug, Deserialize, Clone)]
512pub struct Request {
513 pub method: String,
514 pub url: String,
515
516 #[serde(default)]
517 pub headers: HashMap<String, String>,
518
519 pub auth: Option<AuthConfig>,
521
522 pub body: Option<serde_json::Value>,
524
525 #[serde(default)]
527 pub form: Option<IndexMap<String, String>>,
528
529 pub graphql: Option<GraphqlRequest>,
531
532 pub multipart: Option<MultipartBody>,
534}
535
536#[derive(Debug, Deserialize, Clone)]
538pub struct AuthConfig {
539 pub bearer: Option<String>,
541 pub basic: Option<BasicAuthConfig>,
543}
544
545#[derive(Debug, Deserialize, Clone)]
547pub struct BasicAuthConfig {
548 pub username: String,
549 pub password: String,
550}
551
552#[derive(Debug, Deserialize, Clone)]
554pub struct GraphqlRequest {
555 pub query: String,
557 #[serde(default)]
559 pub variables: Option<serde_json::Value>,
560 pub operation_name: Option<String>,
562}
563
564#[derive(Debug, Deserialize, Clone)]
566pub struct MultipartBody {
567 #[serde(default)]
569 pub fields: Vec<FormField>,
570 #[serde(default)]
572 pub files: Vec<FileField>,
573}
574
575#[derive(Debug, Deserialize, Clone)]
577pub struct FormField {
578 pub name: String,
579 pub value: String,
580}
581
582#[derive(Debug, Deserialize, Clone)]
584pub struct FileField {
585 pub name: String,
587 pub path: String,
589 pub content_type: Option<String>,
591 pub filename: Option<String>,
593}
594
595#[derive(Debug, Deserialize, Clone)]
597pub struct Defaults {
598 #[serde(default)]
599 pub headers: HashMap<String, String>,
600
601 pub auth: Option<AuthConfig>,
603
604 pub timeout: Option<u64>,
606
607 #[serde(alias = "connect-timeout")]
609 pub connect_timeout: Option<u64>,
610
611 #[serde(alias = "follow-redirects")]
613 pub follow_redirects: Option<bool>,
614
615 #[serde(alias = "max-redirs")]
617 pub max_redirs: Option<u32>,
618
619 pub retries: Option<u32>,
621
622 pub delay: Option<String>,
624}
625
626#[derive(Debug, Deserialize, Clone)]
628pub struct Assertion {
629 pub status: Option<StatusAssertion>,
631
632 pub duration: Option<String>,
634
635 pub redirect: Option<RedirectAssertion>,
637
638 pub headers: Option<HashMap<String, String>>,
640
641 pub body: Option<IndexMap<String, serde_yaml::Value>>,
643}
644
645#[derive(Debug, Deserialize, Clone)]
647pub struct RedirectAssertion {
648 pub url: Option<String>,
650 pub count: Option<u32>,
652}
653
654#[derive(Debug, Deserialize, Clone)]
656#[serde(untagged)]
657pub enum StatusAssertion {
658 Exact(u16),
660 Shorthand(String),
662 Complex(StatusSpec),
664}
665
666#[derive(Debug, Deserialize, Clone)]
668pub struct StatusSpec {
669 #[serde(rename = "in")]
671 pub in_set: Option<Vec<u16>>,
672 pub gte: Option<u16>,
674 pub gt: Option<u16>,
676 pub lte: Option<u16>,
678 pub lt: Option<u16>,
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
687 fn deserialize_minimal_test_file() {
688 let yaml = r#"
689name: Health check
690steps:
691 - name: GET /health
692 request:
693 method: GET
694 url: "http://localhost:3000/health"
695 assert:
696 status: 200
697"#;
698 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
699 assert_eq!(tf.name, "Health check");
700 assert_eq!(tf.steps.len(), 1);
701 assert_eq!(tf.steps[0].name, "GET /health");
702 assert_eq!(tf.steps[0].request.method, "GET");
703 assert_eq!(tf.steps[0].request.url, "http://localhost:3000/health");
704 assert!(matches!(
705 tf.steps[0].assertions.as_ref().unwrap().status,
706 Some(StatusAssertion::Exact(200))
707 ));
708 }
709
710 #[test]
711 fn deserialize_full_test_file() {
712 let yaml = r#"
713version: "1"
714name: "User CRUD"
715description: "Tests CRUD lifecycle"
716tags: [crud, users]
717env:
718 base_url: "http://localhost:3000"
719defaults:
720 headers:
721 Content-Type: "application/json"
722 timeout: 5000
723setup:
724 - name: Auth
725 request:
726 method: POST
727 url: "http://localhost:3000/auth"
728 body:
729 email: "admin@test.com"
730 capture:
731 token: "$.token"
732 assert:
733 status: 200
734teardown:
735 - name: Cleanup
736 request:
737 method: POST
738 url: "http://localhost:3000/cleanup"
739tests:
740 create_user:
741 description: "Create a user"
742 tags: [smoke]
743 steps:
744 - name: Create
745 request:
746 method: POST
747 url: "http://localhost:3000/users"
748 headers:
749 Authorization: "Bearer token"
750 body:
751 name: "Jane"
752 capture:
753 user_id: "$.id"
754 assert:
755 status: 201
756 duration: "< 500ms"
757 headers:
758 content-type: contains "application/json"
759 body:
760 "$.name": "Jane"
761"#;
762 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
763 assert_eq!(tf.version, Some("1".into()));
764 assert_eq!(tf.name, "User CRUD");
765 assert_eq!(tf.description, Some("Tests CRUD lifecycle".into()));
766 assert_eq!(tf.tags, vec!["crud", "users"]);
767 assert_eq!(tf.env.get("base_url").unwrap(), "http://localhost:3000");
768
769 let defaults = tf.defaults.as_ref().unwrap();
771 assert_eq!(
772 defaults.headers.get("Content-Type").unwrap(),
773 "application/json"
774 );
775 assert_eq!(defaults.timeout, Some(5000));
776
777 assert_eq!(tf.setup.len(), 1);
779 assert_eq!(tf.setup[0].name, "Auth");
780 assert!(matches!(
781 tf.setup[0].capture.get("token"),
782 Some(CaptureSpec::JsonPath(p)) if p == "$.token"
783 ));
784
785 assert_eq!(tf.teardown.len(), 1);
787
788 assert_eq!(tf.tests.len(), 1);
790 let test = tf.tests.get("create_user").unwrap();
791 assert_eq!(test.description, Some("Create a user".into()));
792 assert_eq!(test.tags, vec!["smoke"]);
793 assert_eq!(test.steps.len(), 1);
794
795 let step = &test.steps[0];
796 assert_eq!(step.name, "Create");
797 assert_eq!(step.request.method, "POST");
798 assert!(step.request.body.is_some());
799 assert!(matches!(
800 step.capture.get("user_id"),
801 Some(CaptureSpec::JsonPath(p)) if p == "$.id"
802 ));
803
804 let assertions = step.assertions.as_ref().unwrap();
805 assert!(matches!(
806 assertions.status,
807 Some(StatusAssertion::Exact(201))
808 ));
809 assert_eq!(assertions.duration, Some("< 500ms".into()));
810 assert!(assertions.headers.is_some());
811 assert!(assertions.body.is_some());
812 }
813
814 #[test]
815 fn deserialize_step_without_assertions() {
816 let yaml = r#"
817name: Fire and forget
818steps:
819 - name: Trigger webhook
820 request:
821 method: POST
822 url: "http://localhost:3000/webhook"
823 body:
824 event: "deploy"
825"#;
826 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
827 assert_eq!(tf.steps.len(), 1);
828 assert!(tf.steps[0].assertions.is_none());
829 }
830
831 #[test]
832 fn deserialize_redirect_assertion() {
833 let yaml = r#"
834name: Redirect assertions
835steps:
836 - name: Follow chain
837 request:
838 method: GET
839 url: "http://localhost:3000/redirect"
840 assert:
841 redirect:
842 url: "http://localhost:3000/final"
843 count: 2
844"#;
845 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
846 let redirect = tf.steps[0]
847 .assertions
848 .as_ref()
849 .and_then(|a| a.redirect.as_ref())
850 .unwrap();
851 assert_eq!(redirect.url.as_deref(), Some("http://localhost:3000/final"));
852 assert_eq!(redirect.count, Some(2));
853 }
854
855 #[test]
856 fn deserialize_empty_optional_fields() {
857 let yaml = r#"
858name: Minimal
859steps:
860 - name: Simple GET
861 request:
862 method: GET
863 url: "http://localhost:3000"
864"#;
865 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
866 assert!(tf.version.is_none());
867 assert!(tf.description.is_none());
868 assert!(tf.tags.is_empty());
869 assert!(tf.env.is_empty());
870 assert!(tf.defaults.is_none());
871 assert!(tf.setup.is_empty());
872 assert!(tf.teardown.is_empty());
873 assert!(tf.tests.is_empty());
874 }
875
876 #[test]
877 fn deserialize_request_with_headers_and_body() {
878 let yaml = r#"
879name: test
880steps:
881 - name: POST with JSON body
882 request:
883 method: POST
884 url: "http://localhost:3000/users"
885 headers:
886 Authorization: "Bearer xyz"
887 X-Custom: "hello"
888 body:
889 name: "Alice"
890 tags: ["a", "b"]
891 nested:
892 key: "value"
893"#;
894 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
895 let req = &tf.steps[0].request;
896 assert_eq!(req.headers.get("Authorization").unwrap(), "Bearer xyz");
897 assert_eq!(req.headers.get("X-Custom").unwrap(), "hello");
898
899 let body = req.body.as_ref().unwrap();
900 assert_eq!(body["name"], "Alice");
901 assert_eq!(body["tags"][0], "a");
902 assert_eq!(body["nested"]["key"], "value");
903 }
904
905 #[test]
906 fn deserialize_request_with_auth_helper() {
907 let yaml = r#"
908name: auth
909steps:
910 - name: GET
911 request:
912 method: GET
913 url: "http://localhost:3000/me"
914 auth:
915 bearer: "{{ env.token }}"
916"#;
917 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
918 let auth = tf.steps[0].request.auth.as_ref().unwrap();
919 assert_eq!(auth.bearer.as_deref(), Some("{{ env.token }}"));
920 assert!(auth.basic.is_none());
921 }
922
923 #[test]
924 fn deserialize_defaults_with_basic_auth_helper() {
925 let yaml = r#"
926name: auth
927defaults:
928 auth:
929 basic:
930 username: "demo"
931 password: "{{ env.password }}"
932steps:
933 - name: GET
934 request:
935 method: GET
936 url: "http://localhost:3000/me"
937"#;
938 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
939 let auth = tf.defaults.as_ref().unwrap().auth.as_ref().unwrap();
940 let basic = auth.basic.as_ref().unwrap();
941 assert_eq!(basic.username, "demo");
942 assert_eq!(basic.password, "{{ env.password }}");
943 }
944
945 #[test]
946 fn tests_preserve_insertion_order() {
947 let yaml = r#"
948name: Order test
949tests:
950 third_test:
951 steps:
952 - name: step
953 request:
954 method: GET
955 url: "http://localhost:3000"
956 first_test:
957 steps:
958 - name: step
959 request:
960 method: GET
961 url: "http://localhost:3000"
962 second_test:
963 steps:
964 - name: step
965 request:
966 method: GET
967 url: "http://localhost:3000"
968"#;
969 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
970 let keys: Vec<&String> = tf.tests.keys().collect();
971 assert_eq!(keys, vec!["third_test", "first_test", "second_test"]);
972 }
973
974 #[test]
975 fn deserialize_body_assertions_with_various_types() {
976 let yaml = r#"
977name: Assertion types
978steps:
979 - name: Check types
980 request:
981 method: GET
982 url: "http://localhost:3000"
983 assert:
984 status: 200
985 body:
986 "$.string": "hello"
987 "$.number": 42
988 "$.bool": true
989 "$.null_field": null
990 "$.complex":
991 type: string
992 contains: "sub"
993"#;
994 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
995 let body = tf.steps[0]
996 .assertions
997 .as_ref()
998 .unwrap()
999 .body
1000 .as_ref()
1001 .unwrap();
1002 assert_eq!(body.len(), 5);
1003 assert!(body.contains_key("$.string"));
1004 assert!(body.contains_key("$.number"));
1005 assert!(body.contains_key("$.bool"));
1006 assert!(body.contains_key("$.null_field"));
1007 assert!(body.contains_key("$.complex"));
1008 }
1009
1010 #[test]
1013 fn deserialize_header_capture() {
1014 let yaml = r#"
1015name: Header capture test
1016steps:
1017 - name: Login
1018 request:
1019 method: POST
1020 url: "http://localhost:3000/login"
1021 capture:
1022 session_token:
1023 header: "set-cookie"
1024 regex: "session_token=([^;]+)"
1025 user_id: "$.id"
1026"#;
1027 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1028 let cap = &tf.steps[0].capture;
1029 assert!(matches!(cap.get("user_id"), Some(CaptureSpec::JsonPath(p)) if p == "$.id"));
1030 match cap.get("session_token") {
1031 Some(CaptureSpec::Extended(ext)) => {
1032 assert_eq!(ext.header.as_deref(), Some("set-cookie"));
1033 assert_eq!(ext.cookie, None);
1034 assert_eq!(ext.body, None);
1035 assert_eq!(ext.status, None);
1036 assert_eq!(ext.url, None);
1037 assert_eq!(ext.regex.as_deref(), Some("session_token=([^;]+)"));
1038 }
1039 other => panic!("Expected Extended capture, got {:?}", other),
1040 }
1041 }
1042
1043 #[test]
1044 fn deserialize_status_capture() {
1045 let yaml = r#"
1046name: Status capture test
1047steps:
1048 - name: Health
1049 request:
1050 method: GET
1051 url: "http://localhost:3000/health"
1052 capture:
1053 status_code:
1054 status: true
1055"#;
1056 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1057 let cap = &tf.steps[0].capture;
1058 match cap.get("status_code") {
1059 Some(CaptureSpec::Extended(ext)) => {
1060 assert_eq!(ext.header, None);
1061 assert_eq!(ext.cookie, None);
1062 assert_eq!(ext.jsonpath, None);
1063 assert_eq!(ext.body, None);
1064 assert_eq!(ext.status, Some(true));
1065 assert_eq!(ext.url, None);
1066 assert_eq!(ext.regex, None);
1067 }
1068 other => panic!("Expected Extended capture, got {:?}", other),
1069 }
1070 }
1071
1072 #[test]
1073 fn deserialize_url_capture() {
1074 let yaml = r#"
1075name: URL capture test
1076steps:
1077 - name: Follow redirect
1078 request:
1079 method: GET
1080 url: "http://localhost:3000/redirect"
1081 capture:
1082 final_url:
1083 url: true
1084"#;
1085 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1086 let cap = &tf.steps[0].capture;
1087 match cap.get("final_url") {
1088 Some(CaptureSpec::Extended(ext)) => {
1089 assert_eq!(ext.header, None);
1090 assert_eq!(ext.cookie, None);
1091 assert_eq!(ext.jsonpath, None);
1092 assert_eq!(ext.body, None);
1093 assert_eq!(ext.status, None);
1094 assert_eq!(ext.url, Some(true));
1095 assert_eq!(ext.regex, None);
1096 }
1097 other => panic!("Expected Extended capture, got {:?}", other),
1098 }
1099 }
1100
1101 #[test]
1102 fn deserialize_cookie_and_body_capture() {
1103 let yaml = r#"
1104name: Cookie capture test
1105steps:
1106 - name: Cookies
1107 request:
1108 method: GET
1109 url: "http://localhost:3000/cookies"
1110 capture:
1111 session_cookie:
1112 cookie: "session"
1113 body_word:
1114 body: true
1115 regex: "plain (text)"
1116"#;
1117 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1118 let cap = &tf.steps[0].capture;
1119 match cap.get("session_cookie") {
1120 Some(CaptureSpec::Extended(ext)) => {
1121 assert_eq!(ext.header, None);
1122 assert_eq!(ext.cookie.as_deref(), Some("session"));
1123 assert_eq!(ext.jsonpath, None);
1124 assert_eq!(ext.body, None);
1125 assert_eq!(ext.status, None);
1126 assert_eq!(ext.url, None);
1127 assert_eq!(ext.regex, None);
1128 }
1129 other => panic!("Expected cookie Extended capture, got {:?}", other),
1130 }
1131 match cap.get("body_word") {
1132 Some(CaptureSpec::Extended(ext)) => {
1133 assert_eq!(ext.header, None);
1134 assert_eq!(ext.cookie, None);
1135 assert_eq!(ext.jsonpath, None);
1136 assert_eq!(ext.body, Some(true));
1137 assert_eq!(ext.status, None);
1138 assert_eq!(ext.url, None);
1139 assert_eq!(ext.regex.as_deref(), Some("plain (text)"));
1140 }
1141 other => panic!("Expected body Extended capture, got {:?}", other),
1142 }
1143 }
1144
1145 #[test]
1148 fn deserialize_status_shorthand() {
1149 let yaml = r#"
1150name: Status range
1151steps:
1152 - name: Check 2xx
1153 request:
1154 method: GET
1155 url: "http://localhost:3000"
1156 assert:
1157 status: "2xx"
1158"#;
1159 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1160 assert!(matches!(
1161 tf.steps[0].assertions.as_ref().unwrap().status,
1162 Some(StatusAssertion::Shorthand(ref s)) if s == "2xx"
1163 ));
1164 }
1165
1166 #[test]
1167 fn deserialize_status_complex_in() {
1168 let yaml = r#"
1169name: Status set
1170steps:
1171 - name: Check set
1172 request:
1173 method: GET
1174 url: "http://localhost:3000"
1175 assert:
1176 status:
1177 in: [200, 201, 204]
1178"#;
1179 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1180 match &tf.steps[0].assertions.as_ref().unwrap().status {
1181 Some(StatusAssertion::Complex(spec)) => {
1182 assert_eq!(spec.in_set.as_ref().unwrap(), &vec![200, 201, 204]);
1183 }
1184 other => panic!("Expected Complex status, got {:?}", other),
1185 }
1186 }
1187
1188 #[test]
1189 fn deserialize_status_complex_range() {
1190 let yaml = r#"
1191name: Status range
1192steps:
1193 - name: Check 4xx range
1194 request:
1195 method: GET
1196 url: "http://localhost:3000"
1197 assert:
1198 status:
1199 gte: 400
1200 lt: 500
1201"#;
1202 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1203 match &tf.steps[0].assertions.as_ref().unwrap().status {
1204 Some(StatusAssertion::Complex(spec)) => {
1205 assert_eq!(spec.gte, Some(400));
1206 assert_eq!(spec.lt, Some(500));
1207 }
1208 other => panic!("Expected Complex status, got {:?}", other),
1209 }
1210 }
1211
1212 #[test]
1215 fn deserialize_multipart_request() {
1216 let yaml = r#"
1217name: Upload test
1218steps:
1219 - name: Upload photo
1220 request:
1221 method: POST
1222 url: "http://localhost:3000/upload"
1223 multipart:
1224 fields:
1225 - name: "title"
1226 value: "My Photo"
1227 files:
1228 - name: "photo"
1229 path: "./fixtures/test.jpg"
1230 content_type: "image/jpeg"
1231"#;
1232 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1233 let mp = tf.steps[0].request.multipart.as_ref().unwrap();
1234 assert_eq!(mp.fields.len(), 1);
1235 assert_eq!(mp.fields[0].name, "title");
1236 assert_eq!(mp.fields[0].value, "My Photo");
1237 assert_eq!(mp.files.len(), 1);
1238 assert_eq!(mp.files[0].name, "photo");
1239 assert_eq!(mp.files[0].path, "./fixtures/test.jpg");
1240 assert_eq!(mp.files[0].content_type.as_deref(), Some("image/jpeg"));
1241 }
1242
1243 #[test]
1244 fn deserialize_form_request() {
1245 let yaml = r#"
1246name: Form test
1247steps:
1248 - name: Submit form
1249 request:
1250 method: POST
1251 url: "http://localhost:3000/login"
1252 form:
1253 email: "user@example.com"
1254 password: "secret"
1255"#;
1256 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1257 let form = tf.steps[0].request.form.as_ref().unwrap();
1258 assert_eq!(
1259 form.get("email").map(String::as_str),
1260 Some("user@example.com")
1261 );
1262 assert_eq!(form.get("password").map(String::as_str), Some("secret"));
1263 }
1264
1265 #[test]
1268 fn deserialize_defaults_with_delay() {
1269 let yaml = r#"
1270name: Delay test
1271defaults:
1272 delay: "100ms"
1273 timeout: 5000
1274steps:
1275 - name: test
1276 request:
1277 method: GET
1278 url: "http://localhost:3000"
1279"#;
1280 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1281 assert_eq!(
1282 tf.defaults.as_ref().unwrap().delay.as_deref(),
1283 Some("100ms")
1284 );
1285 }
1286
1287 #[test]
1290 fn deserialize_step_cookies_false() {
1291 let yaml = r#"
1292name: Step cookies test
1293steps:
1294 - name: No cookies step
1295 cookies: false
1296 request:
1297 method: GET
1298 url: "http://localhost:3000"
1299"#;
1300 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1301 assert_eq!(tf.steps[0].cookies, Some(StepCookies::Enabled(false)));
1302 }
1303
1304 #[test]
1305 fn deserialize_step_cookies_true() {
1306 let yaml = r#"
1307name: Step cookies test
1308steps:
1309 - name: With cookies
1310 cookies: true
1311 request:
1312 method: GET
1313 url: "http://localhost:3000"
1314"#;
1315 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1316 assert_eq!(tf.steps[0].cookies, Some(StepCookies::Enabled(true)));
1317 }
1318
1319 #[test]
1320 fn deserialize_step_cookies_named_jar() {
1321 let yaml = r#"
1322name: Step cookies test
1323steps:
1324 - name: Admin step
1325 cookies: "admin"
1326 request:
1327 method: GET
1328 url: "http://localhost:3000"
1329"#;
1330 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1331 assert_eq!(
1332 tf.steps[0].cookies,
1333 Some(StepCookies::Named("admin".to_string()))
1334 );
1335 }
1336
1337 #[test]
1338 fn deserialize_step_cookies_default_none() {
1339 let yaml = r#"
1340name: Step cookies test
1341steps:
1342 - name: Default step
1343 request:
1344 method: GET
1345 url: "http://localhost:3000"
1346"#;
1347 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1348 assert_eq!(tf.steps[0].cookies, None);
1349 }
1350
1351 #[test]
1352 fn deserialize_cookies_off() {
1353 let yaml = r#"
1354name: No cookies
1355cookies: "off"
1356steps:
1357 - name: test
1358 request:
1359 method: GET
1360 url: "http://localhost:3000"
1361"#;
1362 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1363 assert_eq!(tf.cookies, Some(CookieMode::Off));
1364 }
1365
1366 #[test]
1367 fn deserialize_cookies_auto() {
1368 let yaml = r#"
1369name: Auto cookies
1370cookies: "auto"
1371steps:
1372 - name: test
1373 request:
1374 method: GET
1375 url: "http://localhost:3000"
1376"#;
1377 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1378 assert_eq!(tf.cookies, Some(CookieMode::Auto));
1379 }
1380
1381 #[test]
1382 fn deserialize_cookies_per_test() {
1383 let yaml = r#"
1384name: Per-test cookies
1385cookies: "per-test"
1386tests:
1387 login:
1388 steps:
1389 - name: test
1390 request:
1391 method: GET
1392 url: "http://localhost:3000"
1393"#;
1394 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1395 assert_eq!(tf.cookies, Some(CookieMode::PerTest));
1396 }
1397
1398 #[test]
1399 fn deserialize_cookies_invalid_value_is_rejected() {
1400 let yaml = r#"
1401name: Bad cookies
1402cookies: "sometimes"
1403steps:
1404 - name: test
1405 request:
1406 method: GET
1407 url: "http://localhost:3000"
1408"#;
1409 let err = serde_yaml::from_str::<TestFile>(yaml).unwrap_err();
1410 assert!(
1411 err.to_string().contains("per-test"),
1412 "error should mention the valid options, got: {err}"
1413 );
1414 }
1415
1416 #[test]
1417 fn deserialize_cookies_default_is_none() {
1418 let yaml = r#"
1419name: Default cookies
1420steps:
1421 - name: test
1422 request:
1423 method: GET
1424 url: "http://localhost:3000"
1425"#;
1426 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1427 assert_eq!(tf.cookies, None);
1428 }
1429
1430 #[test]
1431 fn deserialize_redaction_config() {
1432 let yaml = r#"
1433name: Redaction config
1434redaction:
1435 headers:
1436 - authorization
1437 - x-session-token
1438 env:
1439 - api_token
1440 captures:
1441 - session
1442 replacement: "[redacted]"
1443steps:
1444 - name: test
1445 request:
1446 method: GET
1447 url: "http://localhost:3000"
1448"#;
1449
1450 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1451 let redaction = tf.redaction.unwrap();
1452 assert_eq!(redaction.headers, vec!["authorization", "x-session-token"]);
1453 assert_eq!(redaction.env_vars, vec!["api_token"]);
1454 assert_eq!(redaction.captures, vec!["session"]);
1455 assert_eq!(redaction.replacement, "[redacted]");
1456 }
1457
1458 #[test]
1459 fn merge_headers_widens_list_case_insensitively() {
1460 let mut redaction = RedactionConfig {
1461 headers: vec!["authorization".into()],
1462 ..RedactionConfig::default()
1463 };
1464 redaction.merge_headers(["X-Custom-Token", "x-debug"]);
1465 assert_eq!(
1466 redaction.headers,
1467 vec!["authorization", "x-custom-token", "x-debug"]
1468 );
1469 }
1470
1471 #[test]
1472 fn merge_headers_skips_duplicates_ignoring_case() {
1473 let mut redaction = RedactionConfig {
1474 headers: vec!["authorization".into(), "x-api-key".into()],
1475 ..RedactionConfig::default()
1476 };
1477 redaction.merge_headers(["Authorization", "X-API-KEY", "x-new"]);
1478 assert_eq!(
1479 redaction.headers,
1480 vec!["authorization", "x-api-key", "x-new"]
1481 );
1482 }
1483
1484 #[test]
1485 fn merge_headers_trims_and_drops_empty_entries() {
1486 let mut redaction = RedactionConfig::default();
1487 let baseline_len = redaction.headers.len();
1488 redaction.merge_headers(["", " ", " X-Trim "]);
1489 assert_eq!(redaction.headers.len(), baseline_len + 1);
1490 assert!(redaction.headers.iter().any(|h| h == "x-trim"));
1491 }
1492
1493 #[test]
1494 fn merge_headers_never_narrows_existing_list() {
1495 let mut redaction = RedactionConfig {
1496 headers: vec!["authorization".into(), "cookie".into()],
1497 ..RedactionConfig::default()
1498 };
1499 redaction.merge_headers(std::iter::empty::<String>());
1501 assert_eq!(redaction.headers, vec!["authorization", "cookie"]);
1502 }
1503
1504 #[test]
1507 fn deserialize_step_with_description() {
1508 let yaml = r#"
1509name: Step description
1510steps:
1511 - name: GET /health
1512 description: "Verifies the health endpoint stays reachable"
1513 request:
1514 method: GET
1515 url: "http://localhost:3000/health"
1516 assert:
1517 status: 200
1518"#;
1519 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1520 assert_eq!(tf.steps.len(), 1);
1521 assert_eq!(
1522 tf.steps[0].description.as_deref(),
1523 Some("Verifies the health endpoint stays reachable")
1524 );
1525 }
1526
1527 #[test]
1528 fn deserialize_step_description_missing_defaults_to_none() {
1529 let yaml = r#"
1530name: No description
1531steps:
1532 - name: GET /health
1533 request:
1534 method: GET
1535 url: "http://localhost:3000/health"
1536"#;
1537 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1538 assert_eq!(tf.steps.len(), 1);
1539 assert!(tf.steps[0].description.is_none());
1540 }
1541
1542 #[test]
1543 fn deserialize_step_with_folded_multiline_description() {
1544 let yaml = r#"
1545name: Multi-line step description
1546steps:
1547 - name: GET /health
1548 description: |
1549 This step hits the health endpoint.
1550 It verifies the service is reachable.
1551 request:
1552 method: GET
1553 url: "http://localhost:3000/health"
1554 - name: GET /status
1555 description: >
1556 Folded description
1557 on two lines.
1558 request:
1559 method: GET
1560 url: "http://localhost:3000/status"
1561"#;
1562 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1563 assert_eq!(tf.steps.len(), 2);
1564 let literal = tf.steps[0].description.as_deref().unwrap();
1565 assert!(
1566 literal.contains("This step hits the health endpoint.")
1567 && literal.contains("It verifies the service is reachable."),
1568 "literal block should preserve both lines, got: {:?}",
1569 literal
1570 );
1571 assert!(
1572 literal.contains('\n'),
1573 "literal block `|` must keep the newline between lines, got: {:?}",
1574 literal
1575 );
1576 let folded = tf.steps[1].description.as_deref().unwrap();
1577 assert_eq!(folded.trim_end(), "Folded description on two lines.");
1578 }
1579
1580 #[test]
1581 fn deserialize_step_description_inside_named_test() {
1582 let yaml = r#"
1583name: Named tests
1584tests:
1585 smoke:
1586 description: "Smoke tests"
1587 steps:
1588 - name: ping
1589 description: "Ping the service"
1590 request:
1591 method: GET
1592 url: "http://localhost:3000/ping"
1593"#;
1594 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1595 let test = tf.tests.get("smoke").expect("smoke test group");
1596 assert_eq!(test.description.as_deref(), Some("Smoke tests"));
1597 assert_eq!(test.steps.len(), 1);
1598 assert_eq!(
1599 test.steps[0].description.as_deref(),
1600 Some("Ping the service")
1601 );
1602 }
1603
1604 #[test]
1605 fn file_and_test_level_descriptions_still_deserialize() {
1606 let yaml = r#"
1607name: Suite
1608description: "File-level description"
1609tests:
1610 t1:
1611 description: "Group description"
1612 steps:
1613 - name: step
1614 description: "Step description"
1615 request:
1616 method: GET
1617 url: "http://localhost:3000"
1618"#;
1619 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1620 assert_eq!(tf.description.as_deref(), Some("File-level description"));
1621 let group = tf.tests.get("t1").unwrap();
1622 assert_eq!(group.description.as_deref(), Some("Group description"));
1623 assert_eq!(
1624 group.steps[0].description.as_deref(),
1625 Some("Step description")
1626 );
1627 }
1628
1629 #[test]
1632 fn deserialize_capture_with_optional_flag() {
1633 let yaml = r#"
1634name: Optional capture
1635steps:
1636 - name: maybe capture
1637 request:
1638 method: GET
1639 url: "http://localhost:3000/users/1"
1640 capture:
1641 middle_name:
1642 jsonpath: "$.middle_name"
1643 optional: true
1644"#;
1645 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1646 match tf.steps[0].capture.get("middle_name") {
1647 Some(CaptureSpec::Extended(ext)) => {
1648 assert_eq!(ext.optional, Some(true));
1649 assert_eq!(ext.default, None);
1650 assert!(ext.when.is_none());
1651 }
1652 other => panic!("expected Extended capture, got {:?}", other),
1653 }
1654 }
1655
1656 #[test]
1657 fn deserialize_capture_with_default_value_of_various_types() {
1658 let yaml = r#"
1659name: Default capture
1660steps:
1661 - name: step
1662 request:
1663 method: GET
1664 url: "http://localhost:3000"
1665 capture:
1666 count:
1667 jsonpath: "$.count"
1668 default: 0
1669 label:
1670 jsonpath: "$.label"
1671 default: "unnamed"
1672 deleted:
1673 jsonpath: "$.deleted"
1674 default: null
1675"#;
1676 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1677 let caps = &tf.steps[0].capture;
1678
1679 let count = match caps.get("count") {
1680 Some(CaptureSpec::Extended(ext)) => ext,
1681 other => panic!("expected Extended, got {:?}", other),
1682 };
1683 assert_eq!(
1684 count.default.as_ref().and_then(|v| v.as_value().as_i64()),
1685 Some(0),
1686 "numeric default preserved"
1687 );
1688
1689 let label = match caps.get("label") {
1690 Some(CaptureSpec::Extended(ext)) => ext,
1691 other => panic!("expected Extended, got {:?}", other),
1692 };
1693 assert_eq!(
1694 label.default.as_ref().and_then(|v| v.as_value().as_str()),
1695 Some("unnamed"),
1696 "string default preserved"
1697 );
1698
1699 let deleted = match caps.get("deleted") {
1700 Some(CaptureSpec::Extended(ext)) => ext,
1701 other => panic!("expected Extended, got {:?}", other),
1702 };
1703 assert!(
1704 deleted
1705 .default
1706 .as_ref()
1707 .map(|v| v.as_value().is_null())
1708 .unwrap_or(false),
1709 "null default preserved (got {:?})",
1710 deleted.default
1711 );
1712 }
1713
1714 #[test]
1715 fn deserialize_capture_with_when_status_exact() {
1716 let yaml = r#"
1717name: Conditional capture
1718steps:
1719 - name: create if missing
1720 request:
1721 method: PUT
1722 url: "http://localhost:3000/widgets/1"
1723 capture:
1724 created_id:
1725 jsonpath: "$.id"
1726 when:
1727 status: 201
1728"#;
1729 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1730 let ext = match tf.steps[0].capture.get("created_id") {
1731 Some(CaptureSpec::Extended(e)) => e,
1732 other => panic!("expected Extended, got {:?}", other),
1733 };
1734 let when = ext.when.as_ref().expect("when present");
1735 match when.status.as_ref().unwrap() {
1736 StatusAssertion::Exact(201) => {}
1737 other => panic!("expected Exact(201), got {:?}", other),
1738 }
1739 }
1740
1741 #[test]
1742 fn deserialize_capture_with_when_status_set_and_range() {
1743 let yaml = r#"
1744name: Conditional capture sets
1745steps:
1746 - name: pick
1747 request:
1748 method: GET
1749 url: "http://localhost:3000/x"
1750 capture:
1751 ok_id:
1752 jsonpath: "$.id"
1753 when:
1754 status:
1755 in: [200, 201]
1756 err_code:
1757 jsonpath: "$.error.code"
1758 when:
1759 status:
1760 gte: 400
1761 lt: 500
1762"#;
1763 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1764 let caps = &tf.steps[0].capture;
1765
1766 let ok = match caps.get("ok_id") {
1767 Some(CaptureSpec::Extended(e)) => e,
1768 other => panic!("expected Extended, got {:?}", other),
1769 };
1770 match ok.when.as_ref().unwrap().status.as_ref().unwrap() {
1771 StatusAssertion::Complex(spec) => {
1772 assert_eq!(spec.in_set.as_ref().unwrap(), &vec![200, 201]);
1773 }
1774 other => panic!("expected Complex in, got {:?}", other),
1775 }
1776
1777 let err = match caps.get("err_code") {
1778 Some(CaptureSpec::Extended(e)) => e,
1779 other => panic!("expected Extended, got {:?}", other),
1780 };
1781 match err.when.as_ref().unwrap().status.as_ref().unwrap() {
1782 StatusAssertion::Complex(spec) => {
1783 assert_eq!(spec.gte, Some(400));
1784 assert_eq!(spec.lt, Some(500));
1785 }
1786 other => panic!("expected Complex range, got {:?}", other),
1787 }
1788 }
1789
1790 #[test]
1791 fn deserialize_step_with_if_and_unless_fields() {
1792 let yaml = r#"
1793name: Conditional steps
1794steps:
1795 - name: run only if set
1796 if: "{{ capture.request_uuid }}"
1797 request:
1798 method: GET
1799 url: "http://localhost:3000/a"
1800 - name: run only if unset
1801 unless: "{{ capture.request_uuid }}"
1802 request:
1803 method: GET
1804 url: "http://localhost:3000/b"
1805"#;
1806 let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1807 assert_eq!(
1808 tf.steps[0].run_if.as_deref(),
1809 Some("{{ capture.request_uuid }}")
1810 );
1811 assert!(tf.steps[0].unless.is_none());
1812 assert!(tf.steps[1].run_if.is_none());
1813 assert_eq!(
1814 tf.steps[1].unless.as_deref(),
1815 Some("{{ capture.request_uuid }}")
1816 );
1817 }
1818}