1use base64::Engine;
2use serde::{Deserialize, Serialize};
3use std::{
4 borrow::Cow,
5 collections::BTreeMap,
6 fmt::{self, Display, Formatter},
7 time::Duration,
8};
9
10use crate::HttpMethod;
11
12#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
14pub struct CustomHttpConfigV2 {
15 #[serde(skip_serializing_if = "Option::is_none")]
17 pub match_pairing: Option<MatchPairingConfig>,
18
19 #[serde(skip_serializing_if = "Option::is_none")]
23 pub provides: Option<Vec<PairedValidatorConfig>>,
24
25 #[serde(default)]
27 pub calls: Vec<HttpCallConfig>,
28}
29
30impl CustomHttpConfigV2 {
31 pub fn with_call(mut self, call: HttpCallConfig) -> Self {
32 self.calls.push(call);
33 self
34 }
35}
36
37#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
39pub struct MatchPairingConfig {
40 pub kind: String,
42
43 #[serde(flatten)]
45 pub parameters: BTreeMap<String, String>,
46}
47
48impl MatchPairingConfig {
49 pub fn is_fulfilled_by(&self, template_variables: &[TemplateVariable]) -> bool {
50 self.parameters.iter().all(|(_name, template_name)| {
51 template_variables.iter().any(|v| v.name == *template_name)
52 })
53 }
54}
55
56#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
58pub struct HttpCallConfig {
59 pub request: HttpRequestConfig,
60 pub response: HttpResponseConfig,
61}
62
63#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
65pub struct HttpRequestConfig {
66 pub endpoint: TemplatedMatchString,
69
70 pub method: HttpMethod,
71
72 #[serde(default)]
75 pub hosts: Vec<TemplatedMatchString>,
76
77 #[serde(default)]
80 pub headers: BTreeMap<String, TemplatedMatchString>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub body: Option<TemplatedMatchString>,
85
86 #[serde(default = "default_timeout")]
87 pub timeout: Duration,
88}
89
90fn default_timeout() -> Duration {
91 Duration::from_secs(3)
92}
93
94#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
96pub struct HttpResponseConfig {
97 pub conditions: Vec<ResponseCondition>,
100}
101
102#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
104pub struct ResponseCondition {
105 #[serde(rename = "type")]
107 pub condition_type: ResponseConditionType,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub status_code: Option<StatusCodeMatcher>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub raw_body: Option<BodyMatcher>,
116
117 #[serde(skip_serializing_if = "Option::is_none")]
121 pub body: Option<BTreeMap<String, BodyMatcher>>,
122}
123
124impl ResponseCondition {
125 pub fn matches(&self, status_code: u16, body: &str) -> ResponseConditionResult {
140 if let Some(status_code_matcher) = self.status_code.as_ref()
141 && !status_code_matcher.matches(status_code)
142 {
143 ResponseConditionResult::NotChecked
144 } else if let Some(raw_body_matcher) = self.raw_body.as_ref()
145 && !raw_body_matcher.matches(body)
146 {
147 ResponseConditionResult::NotChecked
148 } else if let Some(body_matcher) = self.body.as_ref()
149 && !matches_body(body_matcher, body)
150 {
151 ResponseConditionResult::NotChecked
152 } else {
153 self.condition_type.into()
154 }
155 }
156}
157
158fn matches_body(body_matcher: &BTreeMap<String, BodyMatcher>, body: &str) -> bool {
159 let parsed_body: serde_json::Value = match serde_json::from_str(body) {
160 Ok(value) => value,
161 Err(_) => return false,
162 };
163 for (path, matcher) in body_matcher.iter() {
164 let Some(value) = get_json_path_value(&parsed_body, path) else {
165 continue;
166 };
167 let value_str = match value {
168 serde_json::Value::String(s) => s.clone(),
169 other => other.to_string(),
170 };
171 if matcher.matches(&value_str) {
172 return true;
173 }
174 }
175 false
176}
177
178fn get_json_path_value<'a>(
183 root: &'a serde_json::Value,
184 path: &str,
185) -> Option<&'a serde_json::Value> {
186 let mut cursor = path;
187 let mut value = root;
188
189 if let Some(remaining) = cursor.strip_prefix('$') {
190 cursor = remaining;
191 }
192
193 if cursor.is_empty() {
194 return Some(value);
195 }
196
197 while !cursor.is_empty() {
198 if let Some(remaining) = cursor.strip_prefix('.') {
199 let segment_end = remaining.find(['.', '[']).unwrap_or(remaining.len());
200 if segment_end == 0 {
201 return None;
202 }
203 let key = &remaining[..segment_end];
204 value = value.get(key)?;
205 cursor = &remaining[segment_end..];
206 continue;
207 }
208
209 if let Some(remaining) = cursor.strip_prefix('[') {
210 let closing_bracket = remaining.find(']')?;
211 let segment = &remaining[..closing_bracket];
212 value = if let Ok(index) = segment.parse::<usize>() {
213 value.get(index)?
214 } else {
215 let quoted_key = segment
216 .strip_prefix('"')
217 .and_then(|s| s.strip_suffix('"'))
218 .or_else(|| {
219 segment
220 .strip_prefix('\'')
221 .and_then(|s| s.strip_suffix('\''))
222 })?;
223 value.get(quoted_key)?
224 };
225 cursor = &remaining[closing_bracket + 1..];
226 continue;
227 }
228
229 let segment_end = cursor.find(['.', '[']).unwrap_or(cursor.len());
230 if segment_end == 0 {
231 return None;
232 }
233 let key = &cursor[..segment_end];
234 value = value.get(key)?;
235 cursor = &cursor[segment_end..];
236 }
237
238 Some(value)
239}
240
241pub fn is_valid_body_matcher_path(path: &str) -> bool {
243 let mut cursor = path;
244
245 if let Some(remaining) = cursor.strip_prefix('$') {
246 cursor = remaining;
247 }
248
249 if cursor.is_empty() {
250 return true;
251 }
252
253 while !cursor.is_empty() {
254 if let Some(remaining) = cursor.strip_prefix('.') {
255 let segment_end = remaining.find(['.', '[']).unwrap_or(remaining.len());
256 if segment_end == 0 {
257 return false;
258 }
259 cursor = &remaining[segment_end..];
260 continue;
261 }
262
263 if let Some(remaining) = cursor.strip_prefix('[') {
264 let Some(closing_bracket) = remaining.find(']') else {
265 return false;
266 };
267 let segment = &remaining[..closing_bracket];
268 let is_valid_segment = segment.parse::<usize>().is_ok()
269 || segment
270 .strip_prefix('"')
271 .and_then(|s| s.strip_suffix('"'))
272 .is_some()
273 || segment
274 .strip_prefix('\'')
275 .and_then(|s| s.strip_suffix('\''))
276 .is_some();
277 if !is_valid_segment {
278 return false;
279 }
280 cursor = &remaining[closing_bracket + 1..];
281 continue;
282 }
283
284 let segment_end = cursor.find(['.', '[']).unwrap_or(cursor.len());
285 if segment_end == 0 {
286 return false;
287 }
288 cursor = &cursor[segment_end..];
289 }
290
291 true
292}
293
294#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, Copy)]
296#[serde(rename_all = "lowercase")]
297pub enum ResponseConditionType {
298 Valid,
299 Invalid,
300}
301
302#[derive(Debug, PartialEq)]
303pub enum ResponseConditionResult {
304 Valid,
305 Invalid,
306 NotChecked,
307}
308
309impl From<ResponseConditionType> for ResponseConditionResult {
310 fn from(condition_type: ResponseConditionType) -> Self {
311 match condition_type {
312 ResponseConditionType::Valid => ResponseConditionResult::Valid,
313 ResponseConditionType::Invalid => ResponseConditionResult::Invalid,
314 }
315 }
316}
317
318#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
320#[serde(untagged)]
321pub enum StatusCodeMatcher {
322 Single(u16),
324
325 List(Vec<u16>),
327
328 Range { start: u16, end: u16 },
330}
331
332impl StatusCodeMatcher {
333 pub fn matches(&self, status_code: u16) -> bool {
335 match self {
336 StatusCodeMatcher::Single(code) => status_code == *code,
337 StatusCodeMatcher::List(codes) => codes.contains(&status_code),
338 StatusCodeMatcher::Range { start, end } => status_code >= *start && status_code < *end,
339 }
340 }
341}
342
343#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
345#[serde(tag = "type", content = "config")]
346pub enum BodyMatcher {
347 Present,
349
350 ExactMatch(String),
352
353 Regex(String),
355}
356
357impl BodyMatcher {
358 pub fn matches(&self, body: &str) -> bool {
359 match self {
360 BodyMatcher::Present => !body.is_empty(),
361 BodyMatcher::ExactMatch(value) => body == *value,
362 BodyMatcher::Regex(pattern) => regex::Regex::new(pattern).unwrap().is_match(body),
363 }
364 }
365}
366
367#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
369pub struct PairedValidatorConfig {
370 pub kind: String,
372
373 pub name: String,
376}
377
378#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
379pub struct TemplatedMatchString(pub String);
380
381impl TemplatedMatchString {
382 pub fn render_with_variables(&self, variables: &[TemplateVariable]) -> String {
388 if !may_contain_transform(&self.0) {
389 return substitute_variables(&self.0, variables).into_owned();
390 }
391
392 let segments = parse_template(&self.0);
393 let mut result = String::new();
394 for segment in segments {
395 match segment {
396 TemplateSegment::Literal(s) => {
397 result.push_str(&substitute_variables(s, variables));
398 }
399 TemplateSegment::Transform { kind, content } => {
400 let rendered = substitute_variables(content, variables);
401 result.push_str(&kind.apply(&rendered));
402 }
403 }
404 }
405 result
406 }
407}
408
409#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
413enum TemplateTransform {
414 Base64,
415}
416
417impl TemplateTransform {
418 const ALL: &'static [TemplateTransform] = &[TemplateTransform::Base64];
419
420 fn percent_prefix(self) -> &'static str {
422 match self {
423 TemplateTransform::Base64 => "%base64(",
424 }
425 }
426
427 fn parse(name: &str) -> Option<Self> {
428 match name {
429 "base64" => Some(Self::Base64),
430 _ => None,
431 }
432 }
433
434 fn apply(self, value: &str) -> String {
435 match self {
436 TemplateTransform::Base64 => base64::engine::general_purpose::STANDARD.encode(value),
437 }
438 }
439}
440
441#[derive(Debug, PartialEq)]
442enum TemplateSegment<'a> {
443 Literal(&'a str),
444 Transform {
445 kind: TemplateTransform,
446 content: &'a str,
447 },
448}
449
450fn parse_template(input: &str) -> Vec<TemplateSegment<'_>> {
456 let mut segments = Vec::new();
457 let mut pos = 0;
458
459 while pos < input.len() {
460 match input[pos..].find('%') {
461 Some(offset) => {
463 let pct = pos + offset;
464 if let Some((kind, content_start, content_end)) = try_parse_transform_at(input, pct)
466 {
467 if pct > pos {
469 segments.push(TemplateSegment::Literal(&input[pos..pct]));
470 }
471 segments.push(TemplateSegment::Transform {
473 kind,
474 content: &input[content_start..content_end],
475 });
476 pos = content_end + 1;
477 } else {
478 segments.push(TemplateSegment::Literal(&input[pos..pct + 1]));
480 pos = pct + 1;
481 }
482 }
483 None => {
484 segments.push(TemplateSegment::Literal(&input[pos..]));
486 break;
487 }
488 }
489 }
490
491 segments
492}
493
494fn try_parse_transform_at(input: &str, start: usize) -> Option<(TemplateTransform, usize, usize)> {
498 let after_pct = start + 1;
499 let rest = input.get(after_pct..)?;
500
501 let paren_offset = rest.find('(')?;
502 let name = &rest[..paren_offset];
503 let kind = TemplateTransform::parse(name)?;
504
505 let open_paren = after_pct + paren_offset;
506 let content_start = open_paren + 1;
507 let mut depth: u32 = 1;
508
509 for (offset, byte) in input[content_start..].bytes().enumerate() {
510 match byte {
511 b'(' => depth += 1,
512 b')' => {
513 depth -= 1;
514 if depth == 0 {
515 return Some((kind, content_start, content_start + offset));
516 }
517 }
518 _ => {}
519 }
520 }
521
522 None
523}
524
525fn may_contain_transform(input: &str) -> bool {
526 TemplateTransform::ALL
527 .iter()
528 .any(|t| input.contains(t.percent_prefix()))
529}
530
531fn substitute_variables<'a>(input: &'a str, variables: &[TemplateVariable]) -> Cow<'a, str> {
532 if !input.contains('$') {
533 return Cow::Borrowed(input);
534 }
535 let mut result = input.to_string();
536 for var in variables {
537 result = result.replace(&var.name, &var.value);
538 }
539 Cow::Owned(result)
540}
541
542impl Display for TemplatedMatchString {
543 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
544 write!(f, "{}", self.0)
545 }
546}
547
548#[derive(Clone, Debug, PartialEq, Eq, Hash)]
549pub struct TemplateVariable {
550 pub name: String,
551 pub value: String,
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 #[test]
559 fn test_status_code_matcher_single() {
560 let matcher = StatusCodeMatcher::Single(200);
561 assert!(matcher.matches(200));
562 assert!(!matcher.matches(201));
563 assert!(!matcher.matches(404));
564 }
565
566 #[test]
567 fn test_status_code_matcher_list() {
568 let matcher = StatusCodeMatcher::List(vec![200, 201, 204]);
569 assert!(matcher.matches(200));
570 assert!(matcher.matches(201));
571 assert!(matcher.matches(204));
572 assert!(!matcher.matches(202));
573 assert!(!matcher.matches(404));
574 }
575
576 #[test]
577 fn test_status_code_matcher_range() {
578 let matcher = StatusCodeMatcher::Range {
579 start: 200,
580 end: 300,
581 };
582 assert!(matcher.matches(200));
583 assert!(matcher.matches(250));
584 assert!(matcher.matches(299));
585 assert!(!matcher.matches(199));
586 assert!(!matcher.matches(300));
587 assert!(!matcher.matches(404));
588 }
589
590 #[test]
591 fn test_body_matcher_present() {
592 let matcher = BodyMatcher::Present;
593 assert!(matcher.matches("test"));
594 assert!(!matcher.matches(""));
595 }
596
597 #[test]
598 fn test_body_matcher_exact_match() {
599 let matcher = BodyMatcher::ExactMatch("test".to_string());
600 assert!(matcher.matches("test"));
601 assert!(!matcher.matches("test1"));
602 assert!(!matcher.matches(""));
603 }
604
605 #[test]
606 fn test_body_matcher_regex() {
607 let matcher = BodyMatcher::Regex("test".to_string());
608 assert!(matcher.matches("test"));
609 assert!(matcher.matches("test1"));
610 assert!(!matcher.matches("different"));
611 }
612
613 fn valid_condition_with_status_and_raw_body(
614 status: StatusCodeMatcher,
615 raw_body: BodyMatcher,
616 ) -> ResponseCondition {
617 ResponseCondition {
618 condition_type: ResponseConditionType::Valid,
619 status_code: Some(status),
620 raw_body: Some(raw_body),
621 body: None,
622 }
623 }
624
625 #[test]
628 fn test_condition_status_and_raw_body_both_match_returns_valid() {
629 let cond = valid_condition_with_status_and_raw_body(
630 StatusCodeMatcher::Single(200),
631 BodyMatcher::ExactMatch("ok".to_string()),
632 );
633 assert_eq!(cond.matches(200, "ok"), ResponseConditionResult::Valid);
634 }
635
636 #[test]
637 fn test_condition_status_matches_raw_body_does_not_returns_not_checked() {
638 let cond = valid_condition_with_status_and_raw_body(
639 StatusCodeMatcher::Single(200),
640 BodyMatcher::ExactMatch("ok".to_string()),
641 );
642 assert_eq!(
644 cond.matches(200, "error"),
645 ResponseConditionResult::NotChecked
646 );
647 }
648
649 #[test]
650 fn test_condition_raw_body_matches_status_does_not_returns_not_checked() {
651 let cond = valid_condition_with_status_and_raw_body(
652 StatusCodeMatcher::Single(200),
653 BodyMatcher::ExactMatch("ok".to_string()),
654 );
655 assert_eq!(cond.matches(401, "ok"), ResponseConditionResult::NotChecked);
657 }
658
659 #[test]
660 fn test_condition_status_and_raw_body_neither_matches_returns_not_checked() {
661 let cond = valid_condition_with_status_and_raw_body(
662 StatusCodeMatcher::Single(200),
663 BodyMatcher::ExactMatch("ok".to_string()),
664 );
665 assert_eq!(
666 cond.matches(401, "error"),
667 ResponseConditionResult::NotChecked
668 );
669 }
670
671 #[test]
672 fn test_condition_invalid_type_status_and_raw_body_both_match_returns_invalid() {
673 let cond = ResponseCondition {
674 condition_type: ResponseConditionType::Invalid,
675 status_code: Some(StatusCodeMatcher::Single(401)),
676 raw_body: Some(BodyMatcher::ExactMatch("unauthorized".to_string())),
677 body: None,
678 };
679 assert_eq!(
680 cond.matches(401, "unauthorized"),
681 ResponseConditionResult::Invalid
682 );
683 }
684
685 #[test]
686 fn test_condition_invalid_type_status_matches_raw_body_does_not_returns_not_checked() {
687 let cond = ResponseCondition {
688 condition_type: ResponseConditionType::Invalid,
689 status_code: Some(StatusCodeMatcher::Single(401)),
690 raw_body: Some(BodyMatcher::ExactMatch("unauthorized".to_string())),
691 body: None,
692 };
693 assert_eq!(
695 cond.matches(401, "some other body"),
696 ResponseConditionResult::NotChecked
697 );
698 }
699
700 #[test]
703 fn test_condition_status_and_body_map_both_match_returns_valid() {
704 let mut body_map = BTreeMap::new();
705 body_map.insert(
706 "status".to_string(),
707 BodyMatcher::ExactMatch("active".to_string()),
708 );
709 let cond = ResponseCondition {
710 condition_type: ResponseConditionType::Valid,
711 status_code: Some(StatusCodeMatcher::Single(200)),
712 raw_body: None,
713 body: Some(body_map),
714 };
715 assert_eq!(
716 cond.matches(200, r#"{"status":"active"}"#),
717 ResponseConditionResult::Valid
718 );
719 }
720
721 #[test]
722 fn test_condition_status_matches_body_map_does_not_returns_not_checked() {
723 let mut body_map = BTreeMap::new();
724 body_map.insert(
725 "status".to_string(),
726 BodyMatcher::ExactMatch("active".to_string()),
727 );
728 let cond = ResponseCondition {
729 condition_type: ResponseConditionType::Valid,
730 status_code: Some(StatusCodeMatcher::Single(200)),
731 raw_body: None,
732 body: Some(body_map),
733 };
734 assert_eq!(
736 cond.matches(200, r#"{"status":"inactive"}"#),
737 ResponseConditionResult::NotChecked
738 );
739 }
740
741 #[test]
742 fn test_condition_body_map_matches_status_does_not_returns_not_checked() {
743 let mut body_map = BTreeMap::new();
744 body_map.insert(
745 "status".to_string(),
746 BodyMatcher::ExactMatch("active".to_string()),
747 );
748 let cond = ResponseCondition {
749 condition_type: ResponseConditionType::Valid,
750 status_code: Some(StatusCodeMatcher::Single(200)),
751 raw_body: None,
752 body: Some(body_map),
753 };
754 assert_eq!(
756 cond.matches(401, r#"{"status":"active"}"#),
757 ResponseConditionResult::NotChecked
758 );
759 }
760
761 #[test]
762 fn test_custom_http_v2_config_with_provides() {
763 let config: CustomHttpConfigV2 = serde_yaml::from_str(
764 r#"
765provides:
766 - kind: "vendor_xyz"
767 name: "client_subdomain"
768calls:
769 - request:
770 endpoint: "https://example.com/validate?secret=$MATCH"
771 method: GET
772 response:
773 conditions: []
774"#,
775 )
776 .unwrap();
777
778 assert_eq!(config.provides.as_ref().map(Vec::len), Some(1));
779 let provided = &config.provides.as_ref().unwrap()[0];
780 assert_eq!(provided.kind, "vendor_xyz");
781 assert_eq!(provided.name, "client_subdomain");
782 }
783
784 fn make_exact_body_matcher(path: &str, value: &str) -> BTreeMap<String, BodyMatcher> {
785 BTreeMap::from([(path.to_string(), BodyMatcher::ExactMatch(value.to_string()))])
786 }
787
788 #[test]
789 fn test_get_json_path_value_with_root_prefix() {
790 let body: serde_json::Value =
791 serde_json::from_str(r#"{"a":{"b":[{"c":"value"}]}}"#).unwrap();
792
793 assert_eq!(
794 get_json_path_value(&body, "$.a.b[0].c"),
795 Some(&serde_json::Value::String("value".to_string()))
796 );
797 }
798
799 #[test]
800 fn test_get_json_path_value_without_root_prefix() {
801 let body: serde_json::Value =
802 serde_json::from_str(r#"{"a":{"b":[{"c":"value"}]}}"#).unwrap();
803
804 assert_eq!(
805 get_json_path_value(&body, "a.b[0].c"),
806 Some(&serde_json::Value::String("value".to_string()))
807 );
808 }
809
810 #[test]
811 fn test_get_json_path_value_with_quoted_numeric_key() {
812 let body: serde_json::Value =
813 serde_json::from_str(r#"{"a":{"b":{"0":{"c":"value"}}}}"#).unwrap();
814
815 assert_eq!(
816 get_json_path_value(&body, "$.a.b['0'].c"),
817 Some(&serde_json::Value::String("value".to_string()))
818 );
819 assert_eq!(
820 get_json_path_value(&body, "$.a.b.0.c"),
821 Some(&serde_json::Value::String("value".to_string()))
822 );
823 }
824
825 #[test]
826 fn test_get_json_path_value_returns_root_for_dollar() {
827 let body: serde_json::Value = serde_json::from_str(r#"{"a":1}"#).unwrap();
828
829 assert_eq!(get_json_path_value(&body, "$"), Some(&body));
830 }
831
832 #[test]
833 fn test_get_json_path_value_with_root_array() {
834 let body: serde_json::Value =
835 serde_json::from_str(r#"[{"name":"first"},{"name":"second"}]"#).unwrap();
836
837 assert_eq!(
838 get_json_path_value(&body, "$[1].name"),
839 Some(&serde_json::Value::String("second".to_string()))
840 );
841 }
842
843 #[test]
844 fn test_get_json_path_value_with_nested_arrays() {
845 let body: serde_json::Value =
846 serde_json::from_str(r#"{"a":[{"b":[{"c":"value"}]}]}"#).unwrap();
847
848 assert_eq!(
849 get_json_path_value(&body, "$.a[0].b[0].c"),
850 Some(&serde_json::Value::String("value".to_string()))
851 );
852 }
853
854 #[test]
855 fn test_get_json_path_value_returns_none_for_missing_path() {
856 let body: serde_json::Value =
857 serde_json::from_str(r#"{"a":{"b":[{"c":"value"}]}}"#).unwrap();
858
859 assert_eq!(get_json_path_value(&body, "$.a.b[1].c"), None);
860 }
861
862 #[test]
863 fn test_get_json_path_value_returns_none_for_invalid_quoted_key() {
864 let body: serde_json::Value =
865 serde_json::from_str(r#"{"a":{"b":{"0":{"c":"value"}}}}"#).unwrap();
866
867 assert_eq!(get_json_path_value(&body, "$.a.b[0.c"), None);
868 }
869
870 #[test]
872 fn test_matches_body_jsonpath_array_index() {
873 let body = r#"{"a":{"b":[{"c":"value"}]}}"#;
874 assert!(matches_body(
875 &make_exact_body_matcher("$.a.b[0].c", "value"),
876 body
877 ));
878 }
879
880 #[test]
882 fn test_matches_body_jsonpath_quoted_numeric_key() {
883 let body = r#"{"a":{"b":{"0":{"c":"value"}}}}"#;
884 assert!(matches_body(
885 &make_exact_body_matcher("$.a.b['0'].c", "value"),
886 body
887 ));
888 }
889
890 #[test]
891 fn test_matches_body_jsonpath_without_root_prefix() {
892 let body = r#"{"a":{"b":[{"c":"value"}]}}"#;
893 assert!(matches_body(
894 &make_exact_body_matcher("a.b[0].c", "value"),
895 body
896 ));
897 }
898
899 #[test]
900 fn test_parse_template_no_transforms() {
901 let segments = parse_template("Bearer $MATCH");
902 assert_eq!(segments, vec![TemplateSegment::Literal("Bearer $MATCH")]);
903 }
904
905 #[test]
906 fn test_parse_template_single_transform() {
907 let segments = parse_template("Basic %base64($USER:$MATCH)");
908 assert_eq!(
909 segments,
910 vec![
911 TemplateSegment::Literal("Basic "),
912 TemplateSegment::Transform {
913 kind: TemplateTransform::Base64,
914 content: "$USER:$MATCH"
915 },
916 ]
917 );
918 }
919
920 #[test]
921 fn test_parse_template_transform_only() {
922 let segments = parse_template("%base64(content)");
923 assert_eq!(
924 segments,
925 vec![TemplateSegment::Transform {
926 kind: TemplateTransform::Base64,
927 content: "content"
928 }]
929 );
930 }
931
932 #[test]
933 fn test_parse_template_transform_with_trailing_literal() {
934 let segments = parse_template("%base64(data) suffix");
935 assert_eq!(
936 segments,
937 vec![
938 TemplateSegment::Transform {
939 kind: TemplateTransform::Base64,
940 content: "data"
941 },
942 TemplateSegment::Literal(" suffix"),
943 ]
944 );
945 }
946
947 #[test]
948 fn test_parse_template_multiple_transforms() {
949 let segments = parse_template("a %base64(b) c %base64(d) e");
950 assert_eq!(
951 segments,
952 vec![
953 TemplateSegment::Literal("a "),
954 TemplateSegment::Transform {
955 kind: TemplateTransform::Base64,
956 content: "b"
957 },
958 TemplateSegment::Literal(" c "),
959 TemplateSegment::Transform {
960 kind: TemplateTransform::Base64,
961 content: "d"
962 },
963 TemplateSegment::Literal(" e"),
964 ]
965 );
966 }
967
968 #[test]
969 fn test_parse_template_balanced_parens_in_content() {
970 let segments = parse_template("%base64(a(b)c)");
971 assert_eq!(
972 segments,
973 vec![TemplateSegment::Transform {
974 kind: TemplateTransform::Base64,
975 content: "a(b)c"
976 }]
977 );
978 }
979
980 #[test]
981 fn test_parse_template_literal_percent_not_a_transform() {
982 assert_eq!(
983 parse_template("100%"),
984 vec![TemplateSegment::Literal("100%")]
985 );
986 assert_eq!(
988 parse_template("%20"),
989 vec![
990 TemplateSegment::Literal("%"),
991 TemplateSegment::Literal("20"),
992 ]
993 );
994 assert_eq!(
995 parse_template("a % b"),
996 vec![
997 TemplateSegment::Literal("a %"),
998 TemplateSegment::Literal(" b"),
999 ]
1000 );
1001 }
1002
1003 #[test]
1004 fn test_parse_template_unclosed_paren_treated_as_literal() {
1005 let segments = parse_template("%base64(unclosed");
1006 assert_eq!(
1007 segments,
1008 vec![
1009 TemplateSegment::Literal("%"),
1010 TemplateSegment::Literal("base64(unclosed"),
1011 ]
1012 );
1013 }
1014
1015 #[test]
1016 fn test_render_with_variables_base64_transform() {
1017 let tpl = TemplatedMatchString("Basic %base64($USER:$MATCH)".to_string());
1018 let vars = vec![
1019 TemplateVariable {
1020 name: "$USER".to_string(),
1021 value: "user".to_string(),
1022 },
1023 TemplateVariable {
1024 name: "$MATCH".to_string(),
1025 value: "password".to_string(),
1026 },
1027 ];
1028 assert_eq!(
1029 tpl.render_with_variables(&vars),
1030 "Basic dXNlcjpwYXNzd29yZA=="
1031 );
1032 }
1033
1034 #[test]
1035 fn test_no_render_with_transform_in_variables() {
1036 let tpl = TemplatedMatchString("Basic $USER:$MATCH".to_string());
1037 let vars = vec![
1038 TemplateVariable {
1039 name: "$USER".to_string(),
1040 value: "%base64(user".to_string(),
1041 },
1042 TemplateVariable {
1043 name: "$MATCH".to_string(),
1044 value: "password)".to_string(),
1045 },
1046 ];
1047 assert_eq!(
1048 tpl.render_with_variables(&vars),
1049 "Basic %base64(user:password)"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_render_with_variables_no_transforms() {
1055 let tpl = TemplatedMatchString("Bearer $MATCH".to_string());
1056 let vars = vec![TemplateVariable {
1057 name: "$MATCH".to_string(),
1058 value: "token123%20(bla)".to_string(),
1059 }];
1060 assert_eq!(tpl.render_with_variables(&vars), "Bearer token123%20(bla)");
1061 }
1062
1063 #[test]
1064 fn test_render_with_variables_prevents_injection() {
1065 let tpl = TemplatedMatchString("Basic %base64($USER:$MATCH)".to_string());
1066 let vars = vec![
1067 TemplateVariable {
1068 name: "$USER".to_string(),
1069 value: "%base64(injected)".to_string(),
1070 },
1071 TemplateVariable {
1072 name: "$MATCH".to_string(),
1073 value: "pass".to_string(),
1074 },
1075 ];
1076 assert_eq!(
1079 tpl.render_with_variables(&vars),
1080 "Basic JWJhc2U2NChpbmplY3RlZCk6cGFzcw=="
1081 );
1082 }
1083
1084 #[test]
1085 fn test_render_with_variables_empty_username() {
1086 let tpl = TemplatedMatchString("Basic %base64(:$MATCH)".to_string());
1087 let vars = vec![TemplateVariable {
1088 name: "$MATCH".to_string(),
1089 value: "secret_pass".to_string(),
1090 }];
1091 assert_eq!(tpl.render_with_variables(&vars), "Basic OnNlY3JldF9wYXNz");
1092 }
1093
1094 #[test]
1095 fn test_render_with_variables_unknown_transform_is_literal() {
1096 let tpl = TemplatedMatchString("%unknown(data)".to_string());
1097 assert_eq!(tpl.render_with_variables(&[]), "%unknown(data)");
1098 }
1099
1100 #[test]
1101 fn test_render_with_variables_percent_encoded_not_treated_as_transform() {
1102 let tpl = TemplatedMatchString("%20(hello)".to_string());
1103 assert_eq!(tpl.render_with_variables(&[]), "%20(hello)");
1104 }
1105
1106 #[test]
1107 fn test_render_with_variables_percent_encoded_inside_transform() {
1108 let tpl = TemplatedMatchString("%base64($MATCH%20(bla))".to_string());
1109 let vars = vec![TemplateVariable {
1110 name: "$MATCH".to_string(),
1111 value: "foo".to_string(),
1112 }];
1113 let expected = base64::engine::general_purpose::STANDARD.encode("foo%20(bla)");
1114 assert_eq!(tpl.render_with_variables(&vars), expected);
1115 }
1116
1117 #[test]
1118 fn test_each_transform_lists_percent_prefix_and_round_trips_parse() {
1119 for &t in TemplateTransform::ALL {
1120 let p = t.percent_prefix();
1121 assert!(p.starts_with('%') && p.ends_with('('));
1122 let name = &p[1..p.len() - 1];
1123 assert_eq!(TemplateTransform::parse(name), Some(t));
1124 }
1125 }
1126}