dd_sds/match_validation/
config_v2.rs

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/// Configuration for Online Validation V2
13#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
14pub struct CustomHttpConfigV2 {
15    /// Optional match pairing configuration for validating using matches from multiple rules
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub match_pairing: Option<MatchPairingConfig>,
18
19    /// Optional list of values this rule provides to other paired validators.
20    /// Allows a rule to both self-validate with CustomHttpV2 and contribute its
21    /// match value as named template variables to other CustomHttpV2 rules.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub provides: Option<Vec<PairedValidatorConfig>>,
24
25    /// Array of HTTP calls to attempt. Only one needs to succeed for validation.
26    #[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/// Configuration for pairing matches from multiple rules together
38#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
39pub struct MatchPairingConfig {
40    /// Vendor identifier to match across rules.
41    pub kind: String,
42
43    /// Map of parameter names to template variables
44    #[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/// A single HTTP call configuration with request and response validation
57#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
58pub struct HttpCallConfig {
59    pub request: HttpRequestConfig,
60    pub response: HttpResponseConfig,
61}
62
63/// HTTP request configuration with templating support
64#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
65pub struct HttpRequestConfig {
66    /// Endpoint URL with optional template variables
67    /// Example: "https://$CLIENT_SUBDOMAIN.vendor.com/api/0/organizations/$MATCH"
68    pub endpoint: TemplatedMatchString,
69
70    pub method: HttpMethod,
71
72    /// Optional list of templated hosts for multi-datacenter support
73    /// If specified, $HOST in endpoint will be replaced with each host
74    #[serde(default)]
75    pub hosts: Vec<TemplatedMatchString>,
76
77    /// Request headers with template variable support
78    /// Example: {"Authorization": "Basic %base64($CLIENT_ID:$MATCH)"}
79    #[serde(default)]
80    pub headers: BTreeMap<String, TemplatedMatchString>,
81
82    /// Optional request body with template variable support
83    #[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/// Response validation configuration with multiple condition support
95#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
96pub struct HttpResponseConfig {
97    /// Array of response conditions to check
98    /// Conditions are evaluated sequentially until one matches
99    pub conditions: Vec<ResponseCondition>,
100}
101
102/// A response condition that determines if a secret is valid or invalid
103#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
104pub struct ResponseCondition {
105    /// Whether this condition indicates a valid or invalid secret
106    #[serde(rename = "type")]
107    pub condition_type: ResponseConditionType,
108
109    /// Optional status code matcher
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub status_code: Option<StatusCodeMatcher>,
112
113    /// Optional raw body matcher (before parsing)
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub raw_body: Option<BodyMatcher>,
116
117    /// Optional parsed body matchers (after JSON parsing)
118    /// Maps JSON paths to matchers
119    /// Example: {"$.message.stack[2].success.status": BodyMatcher}
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub body: Option<BTreeMap<String, BodyMatcher>>,
122}
123
124impl ResponseCondition {
125    /// Determines if a ResponseCondition matches a given status code and body
126    /// It does this by checking against each of the optional conditions and aggregating the results.
127    ///
128    /// Consider the example of a ResponseCondition with the following conditions:
129    /// - status_code: 200
130    /// - raw_body: "success"
131    ///
132    /// If the status code is 200, the raw body is "success", ResponseCondition matches.
133    /// If the status code is 200, the raw body is "failure", then the ResponseCondition does not match.
134    ///
135    /// What happens next depends on the ResponseConditionType and whether or not the condition matched:
136    /// * Matched and Invalid -> Invalid
137    /// * Matched and Valid -> Valid
138    /// * Not matched -> NotChecked
139    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
178/// Get the value at a given JSONPath
179///
180/// Simple parser as we don't need extensive JSONPath support and can thus avoid
181/// pulling in a heavy JSONPath library.
182fn 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
241/// Used for validating the body matcher path syntax
242pub 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/// Type of response condition
295#[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/// Status code matcher supporting single, list, or range
319#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
320#[serde(untagged)]
321pub enum StatusCodeMatcher {
322    /// Single status code: 200
323    Single(u16),
324
325    /// List of status codes: [401, 403, 404]
326    List(Vec<u16>),
327
328    /// Range of status codes: {"start": 400, "end": 420}
329    Range { start: u16, end: u16 },
330}
331
332impl StatusCodeMatcher {
333    /// Check if a status code matches this matcher
334    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/// Body content matcher
344#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
345#[serde(tag = "type", content = "config")]
346pub enum BodyMatcher {
347    /// Check that the body/field is present (not null/undefined)
348    Present,
349
350    /// Check for exact string match
351    ExactMatch(String),
352
353    /// Check if the value matches a regex pattern
354    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/// Secondary validator type for rules that forward their match to a paired validator
368#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
369pub struct PairedValidatorConfig {
370    /// Vendor identifier to match the main validator
371    pub kind: String,
372
373    /// Name of the parameter this rule provides
374    /// Example: "client_id", "client_subdomain"
375    pub name: String,
376}
377
378#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
379pub struct TemplatedMatchString(pub String);
380
381impl TemplatedMatchString {
382    /// Render the template by substituting all variables and applying transformations.
383    ///
384    /// The template string is parsed BEFORE variable substitution so that
385    /// transform-like syntax (e.g. `%base64(`) inside variable values is never
386    /// interpreted as a transformation.
387    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/// Built-in `%name(content)` transforms for [`TemplatedMatchString`].
410///
411/// Add a variant when introducing a new transform; `apply` must stay exhaustive.
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
413enum TemplateTransform {
414    Base64,
415}
416
417impl TemplateTransform {
418    const ALL: &'static [TemplateTransform] = &[TemplateTransform::Base64];
419
420    /// `%name(` — used by [`may_contain_transform`] to skip parsing when no transform syntax is present.
421    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
450/// Parse a template string into literal and transform segments.
451///
452/// Transform syntax: `%name(content)` where `name` is an alphanumeric/underscore
453/// identifier and parentheses inside `content` are balanced.
454/// A `%` that doesn't start a valid transform is kept as a literal character.
455fn 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            // Found a '%' that might be the start of a transform
462            Some(offset) => {
463                let pct = pos + offset;
464                // Can we parse the transform name?
465                if let Some((kind, content_start, content_end)) = try_parse_transform_at(input, pct)
466                {
467                    // Add any literal text before the transform
468                    if pct > pos {
469                        segments.push(TemplateSegment::Literal(&input[pos..pct]));
470                    }
471                    // Add the transform segment (transform to apply, and the content to apply it to)
472                    segments.push(TemplateSegment::Transform {
473                        kind,
474                        content: &input[content_start..content_end],
475                    });
476                    pos = content_end + 1;
477                } else {
478                    // No, likely a URL encoding or similar
479                    segments.push(TemplateSegment::Literal(&input[pos..pct + 1]));
480                    pos = pct + 1;
481                }
482            }
483            None => {
484                // No more '%' found, add any remaining literal text
485                segments.push(TemplateSegment::Literal(&input[pos..]));
486                break;
487            }
488        }
489    }
490
491    segments
492}
493
494/// Try to parse `%name(content)` starting at `start` which points to `%`.
495/// On success returns `(kind, content_start, content_end)` where content is
496/// `input[content_start..content_end]` and the closing `)` is at `content_end`.
497fn 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    // status_code + raw_body: all four combinations of match/miss
626
627    #[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        // status matches but body does not — all sub-conditions must agree
643        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        // body matches but status does not — all sub-conditions must agree
656        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        // status matches but body does not — the Invalid condition should not fire
694        assert_eq!(
695            cond.matches(401, "some other body"),
696            ResponseConditionResult::NotChecked
697        );
698    }
699
700    // status_code + body (BTreeMap) combinations
701
702    #[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        // status matches but the body field does not — condition should not fire
735        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        // body field matches but status does not — condition should not fire
755        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    // JSONPath $.a.b[0].c selects the first element from array b.
871    #[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    // JSONPath $.a.b['0'].c makes object-key access explicit when the key is numeric.
881    #[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        // "%20" has no '(' so '%' is literal; parser splits at '%'.
987        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        // The %base64( inside $USER's value must NOT be interpreted as a transform.
1077        // The entire "$USER:$MATCH" content is substituted, then base64-encoded.
1078        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}