1use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
22use reqwest::{Client, Method};
23use std::collections::BTreeMap;
24use std::net::IpAddr;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29const CAPTURE_BODY_CAP_BYTES: usize = 16 * 1024;
35
36const SCHEMA_MUTATION_CAP: usize = 12;
43
44#[derive(Debug, Clone)]
46pub struct SelfTestConfig {
47 pub target_url: String,
48 pub skip_tls_verify: bool,
49 pub timeout: Duration,
50 pub extra_headers: Vec<(String, String)>,
52 pub delay_between_requests: Duration,
54 pub base_path: Option<String>,
61 pub source_ips: Vec<IpAddr>,
65 pub geo_source_ips: Vec<IpAddr>,
69 pub geo_source_headers: Vec<String>,
73 pub capture: Option<Arc<Mutex<Vec<CaseCapture>>>>,
79}
80
81#[derive(Debug, Clone, serde::Serialize)]
88pub struct CaseCapture {
89 pub label: String,
90 pub method: String,
91 pub url: String,
92 pub request_headers: BTreeMap<String, String>,
93 pub request_body: Option<String>,
94 pub request_body_truncated: bool,
95 pub response_status: u16,
96 pub response_headers: BTreeMap<String, String>,
97 pub response_body: Option<String>,
98 pub response_body_truncated: bool,
99 pub error: Option<String>,
100}
101
102impl Default for SelfTestConfig {
103 fn default() -> Self {
104 Self {
105 target_url: "http://localhost:3000".into(),
106 skip_tls_verify: false,
107 timeout: Duration::from_secs(15),
108 extra_headers: Vec::new(),
109 delay_between_requests: Duration::from_millis(0),
110 base_path: None,
111 source_ips: Vec::new(),
112 geo_source_ips: Vec::new(),
113 geo_source_headers: default_geo_source_headers(),
114 capture: None,
115 }
116 }
117}
118
119fn truncate_body_for_capture(body: &str) -> (String, bool) {
123 if body.len() <= CAPTURE_BODY_CAP_BYTES {
124 return (body.to_string(), false);
125 }
126 let mut end = CAPTURE_BODY_CAP_BYTES;
127 while end > 0 && !body.is_char_boundary(end) {
128 end -= 1;
129 }
130 (body[..end].to_string(), true)
131}
132
133pub fn default_geo_source_headers() -> Vec<String> {
140 vec![
141 "X-Forwarded-For".to_string(),
142 "True-Client-IP".to_string(),
143 "CF-Connecting-IP".to_string(),
144 ]
145}
146
147#[derive(Debug, Clone, serde::Serialize)]
149pub struct CaseOutcome {
150 pub label: String,
151 pub expected_4xx: bool,
152 pub actual_status: u16,
153 pub passed: bool,
156}
157
158#[derive(Debug, Clone, serde::Serialize)]
160pub struct OperationResult {
161 pub method: String,
162 pub path: String,
163 pub positive: Option<CaseOutcome>,
164 pub negatives: Vec<CaseOutcome>,
165}
166
167#[derive(Debug, Default, Clone, serde::Serialize)]
169pub struct SelfTestReport {
170 pub positive_pass: usize,
171 pub positive_fail: usize,
172 pub negative_caught: BTreeMap<String, usize>,
175 pub negative_missed: BTreeMap<String, usize>,
178 pub operations: Vec<OperationResult>,
179}
180
181impl SelfTestReport {
182 pub fn all_passed(&self) -> bool {
185 self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
186 }
187
188 pub fn detect_target_misconfiguration(&self) -> Option<u16> {
197 if self.positive_pass > 0 || self.positive_fail < 10 {
198 return None;
199 }
200 let mut seen: Option<u16> = None;
201 for op in &self.operations {
202 let Some(p) = &op.positive else {
203 continue;
204 };
205 if p.passed {
206 return None;
207 }
208 match seen {
209 None => seen = Some(p.actual_status),
210 Some(s) if s != p.actual_status => return None,
211 _ => {}
212 }
213 }
214 seen
215 }
216
217 pub fn render_summary(&self) -> String {
221 let mut out = String::new();
222 out.push_str(&format!(
223 "Positives: {} pass / {} fail\n",
224 self.positive_pass, self.positive_fail
225 ));
226 let mut keys: Vec<&String> =
227 self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
228 keys.sort();
229 keys.dedup();
230 for cat in keys {
231 let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
232 let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
233 let mark = if missed == 0 { "✓" } else { "⚠" };
234 out.push_str(&format!(
235 "Negatives [{}]: {} caught / {} missed {}\n",
236 cat, caught, missed, mark
237 ));
238 }
239 out
240 }
241}
242
243pub async fn run_self_test(
248 operations: &[AnnotatedOperation],
249 config: &SelfTestConfig,
250) -> Result<SelfTestReport, reqwest::Error> {
251 let clients = build_client_pool(config)?;
256 let client_cursor = AtomicUsize::new(0);
257 let geo_cursor = AtomicUsize::new(0);
258
259 let mut report = SelfTestReport::default();
260 for op in operations {
261 let client_idx = client_cursor.fetch_add(1, Ordering::Relaxed) % clients.len();
262 let client = &clients[client_idx];
263 let geo_ip = if config.geo_source_ips.is_empty() {
264 None
265 } else {
266 let idx = geo_cursor.fetch_add(1, Ordering::Relaxed) % config.geo_source_ips.len();
267 Some(config.geo_source_ips[idx])
268 };
269 let result = test_operation(client, config, op, geo_ip).await;
270 if let Some(p) = &result.positive {
271 if p.passed {
272 report.positive_pass += 1;
273 } else {
274 report.positive_fail += 1;
275 }
276 }
277 for neg in &result.negatives {
278 let cat = neg.label.split(':').next().unwrap_or("other").to_string();
279 if neg.passed {
280 *report.negative_caught.entry(cat).or_insert(0) += 1;
281 } else {
282 *report.negative_missed.entry(cat).or_insert(0) += 1;
283 }
284 }
285 report.operations.push(result);
286 if !config.delay_between_requests.is_zero() {
287 tokio::time::sleep(config.delay_between_requests).await;
288 }
289 }
290 Ok(report)
291}
292
293fn effective_op_headers(
301 base: &[(String, String)],
302 geo_ip: Option<IpAddr>,
303 geo_headers: &[String],
304) -> Vec<(String, String)> {
305 let mut out = base.to_vec();
306 let Some(ip) = geo_ip else {
307 return out;
308 };
309 let value = ip.to_string();
310 for h in geo_headers {
311 if out.iter().any(|(k, _)| k.eq_ignore_ascii_case(h)) {
314 continue;
315 }
316 out.push((h.clone(), value.clone()));
317 }
318 out
319}
320
321fn build_client_pool(config: &SelfTestConfig) -> Result<Vec<Client>, reqwest::Error> {
329 let make = |bind: Option<IpAddr>| -> Result<Client, reqwest::Error> {
330 let mut builder = Client::builder().timeout(config.timeout);
331 if config.skip_tls_verify {
332 builder = builder.danger_accept_invalid_certs(true);
333 }
334 if let Some(addr) = bind {
335 builder = builder.local_address(addr);
336 }
337 builder.build()
338 };
339 if config.source_ips.is_empty() {
340 Ok(vec![make(None)?])
341 } else {
342 config.source_ips.iter().map(|ip| make(Some(*ip))).collect()
343 }
344}
345
346async fn test_operation(
347 client: &Client,
348 config: &SelfTestConfig,
349 op: &AnnotatedOperation,
350 geo_ip: Option<IpAddr>,
351) -> OperationResult {
352 let url = build_url_with_base(
353 &config.target_url,
354 config.base_path.as_deref(),
355 &op.path,
356 &op.path_params,
357 );
358 let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
359
360 let op_headers = effective_op_headers(&op.header_params, geo_ip, &config.geo_source_headers);
365
366 let positive = send_case(
368 client,
369 config,
370 method.clone(),
371 &url,
372 "positive",
373 false,
374 op.sample_body.as_deref(),
375 op.query_params.clone(),
376 op_headers.clone(),
377 )
378 .await;
379
380 let mut negatives = Vec::new();
382
383 if op.request_body_content_type.is_some() {
392 negatives.push(
393 send_case(
394 client,
395 config,
396 method.clone(),
397 &url,
398 "request-body:empty",
399 true,
400 Some("{}"),
401 op.query_params.clone(),
402 op_headers.clone(),
403 )
404 .await,
405 );
406
407 negatives.push(
411 send_case(
412 client,
413 config,
414 method.clone(),
415 &url,
416 "request-body:wrong-type",
417 true,
418 Some("[]"),
419 op.query_params.clone(),
420 op_headers.clone(),
421 )
422 .await,
423 );
424
425 if let (Some(sample_str), Some(schema)) =
434 (op.sample_body.as_deref(), op.request_body_schema.as_ref())
435 {
436 if let Ok(sample) = serde_json::from_str::<serde_json::Value>(sample_str) {
437 let mutations = super::schema_mutator::mutate_body(&sample, schema);
438 for m in mutations.into_iter().take(SCHEMA_MUTATION_CAP) {
439 let body_str = serde_json::to_string(&m.body).unwrap_or_default();
440 negatives.push(
441 send_case(
442 client,
443 config,
444 method.clone(),
445 &url,
446 &m.label,
447 true,
448 Some(&body_str),
449 op.query_params.clone(),
450 op_headers.clone(),
457 )
458 .await,
459 );
460 }
461 }
462 }
463 }
464
465 {
470 let pad = "p=".to_string() + &"x".repeat(9_000);
471 let bad_url = if url.contains('?') {
472 format!("{url}&{pad}")
473 } else {
474 format!("{url}?{pad}")
475 };
476 negatives.push(
477 send_case(
478 client,
479 config,
480 method.clone(),
481 &bad_url,
482 "parameters:uri-too-long",
483 true,
484 op.sample_body.as_deref(),
485 op.query_params.clone(),
486 op_headers.clone(),
490 )
491 .await,
492 );
493 }
494
495 if !op.path_params.is_empty() {
509 let mut url_with_placeholder = op.path.clone();
510 if let Some((first_name, _)) = op.path_params.first() {
511 for (name, value) in op.path_params.iter().skip(1) {
514 if !value.is_empty() {
515 url_with_placeholder =
516 url_with_placeholder.replace(&format!("{{{name}}}"), value);
517 }
518 }
519 url_with_placeholder =
523 url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
524 let bad_url = build_url_with_base(
528 &config.target_url,
529 config.base_path.as_deref(),
530 &url_with_placeholder,
531 &[],
532 );
533 negatives.push(
534 send_case(
535 client,
536 config,
537 method.clone(),
538 &bad_url,
539 "parameters:bad-path-param",
540 true,
541 op.sample_body.as_deref(),
542 op.query_params.clone(),
543 op_headers.clone(),
544 )
545 .await,
546 );
547 }
548 }
549
550 if !op.query_params.is_empty() {
552 let mut q = op.query_params.clone();
553 q.remove(0);
554 negatives.push(
555 send_case(
556 client,
557 config,
558 method.clone(),
559 &url,
560 "parameters:missing-query",
561 true,
562 op.sample_body.as_deref(),
563 q,
564 op_headers.clone(),
565 )
566 .await,
567 );
568 }
569
570 for probe in build_security_probes(&op.security_schemes) {
588 let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
592 let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
593 let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
594 let mut req_headers = stripped_headers;
595 for (k, v) in &probe.headers {
596 req_headers.push((k.clone(), v.clone()));
597 }
598 if let Some(ip) = geo_ip {
604 let ip_str = ip.to_string();
605 for h in &config.geo_source_headers {
606 let already = req_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(h));
607 if !already {
608 req_headers.push((h.clone(), ip_str.clone()));
609 }
610 }
611 }
612 let mut req_query = stripped_query;
613 for (k, v) in &probe.query {
614 req_query.push((k.clone(), v.clone()));
615 }
616 negatives.push(
617 send_case_with_extra(
618 client,
619 config,
620 method.clone(),
621 &url,
622 &probe.label,
623 true,
624 op.sample_body.as_deref(),
625 req_query,
626 req_headers,
627 stripped_extra,
628 )
629 .await,
630 );
631 }
632
633 if !op.header_params.is_empty() {
635 let mut h = op_headers.clone();
641 if !h.is_empty() {
642 h.remove(0);
643 }
644 negatives.push(
645 send_case(
646 client,
647 config,
648 method.clone(),
649 &url,
650 "parameters:missing-header",
651 true,
652 op.sample_body.as_deref(),
653 op.query_params.clone(),
654 h,
655 )
656 .await,
657 );
658 }
659
660 for probe in build_owasp_probes(op) {
680 negatives.push(
681 send_case(
682 client,
683 config,
684 method.clone(),
685 &url,
686 &probe.label,
687 true,
688 probe.body.as_deref(),
689 probe.query,
690 op_headers.clone(),
695 )
696 .await,
697 );
698 }
699
700 OperationResult {
701 method: op.method.clone(),
702 path: op.path.clone(),
703 positive: Some(positive),
704 negatives,
705 }
706}
707
708#[derive(Debug, Clone)]
710struct OwaspProbe {
711 label: String,
712 body: Option<String>,
713 query: Vec<(String, String)>,
714}
715
716fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
720 use crate::security_payloads::{SecurityCategory, SecurityPayloads};
721
722 let categories = [
723 SecurityCategory::SqlInjection,
724 SecurityCategory::Xss,
725 SecurityCategory::CommandInjection,
726 SecurityCategory::PathTraversal,
727 SecurityCategory::Ssti,
728 SecurityCategory::LdapInjection,
729 SecurityCategory::Xxe,
730 ];
731
732 let injection_target = pick_injection_target(op);
736 let Some(target) = injection_target else {
737 return Vec::new();
738 };
739
740 let mut probes = Vec::new();
741 for cat in categories {
742 let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
747 continue;
748 };
749 let mut query = op.query_params.clone();
750 let mut body = op.sample_body.clone();
751 match &target {
752 InjectionTarget::Query(idx) => {
753 if let Some(slot) = query.get_mut(*idx) {
754 slot.1 = payload.payload.clone();
755 }
756 }
757 InjectionTarget::BodyStringField(field) => {
758 body = inject_into_body_field(body.as_deref(), field, &payload.payload);
759 }
760 }
761 probes.push(OwaspProbe {
762 label: format!("owasp:{}", cat),
763 body,
764 query,
765 });
766 }
767 probes
768}
769
770#[derive(Debug, Clone)]
771enum InjectionTarget {
772 Query(usize),
773 BodyStringField(String),
774}
775
776fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
777 if !op.query_params.is_empty() {
778 return Some(InjectionTarget::Query(0));
779 }
780 let sample = op.sample_body.as_deref()?;
781 let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
782 let obj = parsed.as_object()?;
783 for (k, v) in obj {
784 if v.is_string() {
785 return Some(InjectionTarget::BodyStringField(k.clone()));
786 }
787 }
788 None
789}
790
791fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
795 let raw = body?;
796 let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
797 let obj = parsed.as_object_mut()?;
798 obj.insert(field.to_string(), serde_json::json!(payload));
799 serde_json::to_string(&parsed).ok()
800}
801
802#[allow(clippy::too_many_arguments)]
803#[derive(Debug, Clone)]
805struct SecurityProbe {
806 label: String,
808 headers: Vec<(String, String)>,
810 query: Vec<(String, String)>,
812}
813
814fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
820 if schemes.is_empty() {
821 return Vec::new();
822 }
823 let mut probes: Vec<SecurityProbe> = Vec::new();
824 let mut seen_bearer = false;
825 let mut seen_basic = false;
826 let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
829 for s in schemes {
830 match s {
831 SecuritySchemeInfo::Bearer if !seen_bearer => {
832 seen_bearer = true;
833 probes.push(SecurityProbe {
834 label: "security:bad-bearer".into(),
835 headers: vec![(
836 "Authorization".into(),
837 "Bearer self-test-invalid-token".into(),
838 )],
839 query: Vec::new(),
840 });
841 }
842 SecuritySchemeInfo::Basic if !seen_basic => {
843 seen_basic = true;
844 probes.push(SecurityProbe {
846 label: "security:bad-basic".into(),
847 headers: vec![(
848 "Authorization".into(),
849 "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
850 )],
851 query: Vec::new(),
852 });
853 }
854 SecuritySchemeInfo::ApiKey { location, name } => {
855 let loc_tag = match location {
856 ApiKeyLocation::Header => "header",
857 ApiKeyLocation::Query => "query",
858 ApiKeyLocation::Cookie => "cookie",
859 };
860 if seen_apikey.contains(&(loc_tag, name.clone())) {
861 continue;
862 }
863 seen_apikey.insert((loc_tag, name.clone()));
864 let label = format!("security:bad-apikey:{}", name);
865 let bad = "self-test-invalid-key".to_string();
866 match location {
867 ApiKeyLocation::Header => probes.push(SecurityProbe {
868 label,
869 headers: vec![(name.clone(), bad)],
870 query: Vec::new(),
871 }),
872 ApiKeyLocation::Query => probes.push(SecurityProbe {
873 label,
874 headers: Vec::new(),
875 query: vec![(name.clone(), bad)],
876 }),
877 ApiKeyLocation::Cookie => probes.push(SecurityProbe {
878 label,
879 headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
880 query: Vec::new(),
881 }),
882 }
883 }
884 _ => {}
885 }
886 }
887 probes.push(SecurityProbe {
892 label: "security:no-auth".into(),
893 headers: Vec::new(),
894 query: Vec::new(),
895 });
896 probes
897}
898
899fn strip_auth(
903 headers: &[(String, String)],
904 schemes: &[SecuritySchemeInfo],
905) -> Vec<(String, String)> {
906 let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
907 for s in schemes {
908 if let SecuritySchemeInfo::ApiKey {
909 location: ApiKeyLocation::Header,
910 name,
911 } = s
912 {
913 apikey_headers.insert(name.to_lowercase());
914 }
915 if let SecuritySchemeInfo::ApiKey {
916 location: ApiKeyLocation::Cookie,
917 ..
918 } = s
919 {
920 apikey_headers.insert("cookie".into());
921 }
922 }
923 headers
924 .iter()
925 .filter(|(k, _)| {
926 let lk = k.to_lowercase();
927 lk != "authorization" && !apikey_headers.contains(&lk)
928 })
929 .cloned()
930 .collect()
931}
932
933fn strip_auth_query(
936 query: &[(String, String)],
937 schemes: &[SecuritySchemeInfo],
938) -> Vec<(String, String)> {
939 let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
940 for s in schemes {
941 if let SecuritySchemeInfo::ApiKey {
942 location: ApiKeyLocation::Query,
943 name,
944 } = s
945 {
946 apikey_query.insert(name.clone());
947 }
948 }
949 query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
950}
951
952#[allow(clippy::too_many_arguments)]
956async fn send_case_with_extra(
957 client: &Client,
958 config: &SelfTestConfig,
959 method: Method,
960 url: &str,
961 label: &str,
962 expected_4xx: bool,
963 body: Option<&str>,
964 query: Vec<(String, String)>,
965 headers: Vec<(String, String)>,
966 extra_headers: Vec<(String, String)>,
967) -> CaseOutcome {
968 let mut req = client.request(method.clone(), url);
969 let mut capture_headers: BTreeMap<String, String> = BTreeMap::new();
970 for (k, v) in &query {
971 req = req.query(&[(k.as_str(), v.as_str())]);
972 }
973 for (k, v) in &headers {
974 req = req.header(k, v);
975 capture_headers.insert(k.clone(), v.clone());
976 }
977 for (k, v) in &extra_headers {
978 req = req.header(k, v);
979 capture_headers.insert(k.clone(), v.clone());
980 }
981 if let Some(b) = body {
982 req = req
983 .header(reqwest::header::CONTENT_TYPE, "application/json")
984 .body(b.to_string());
985 capture_headers.insert("Content-Type".to_string(), "application/json".to_string());
986 }
987 let (actual_status, response_capture) = match req.send().await {
988 Ok(resp) => {
989 let status = resp.status().as_u16();
990 if let Some(sink) = &config.capture {
991 let resp_headers: BTreeMap<String, String> = resp
992 .headers()
993 .iter()
994 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
995 .collect();
996 let text = resp.text().await.unwrap_or_default();
997 let (rb, truncated) = truncate_body_for_capture(&text);
998 (status, Some((Some((rb, truncated)), resp_headers, None, sink.clone())))
999 } else {
1000 (status, None)
1001 }
1002 }
1003 Err(e) => {
1004 let err_str = e.to_string();
1005 if let Some(sink) = &config.capture {
1006 (0, Some((None, BTreeMap::new(), Some(err_str), sink.clone())))
1007 } else {
1008 (0, None)
1009 }
1010 }
1011 };
1012 let passed = if expected_4xx {
1013 (400..500).contains(&actual_status)
1014 } else {
1015 (200..400).contains(&actual_status)
1016 };
1017 if let Some((resp_body, resp_headers, error, sink)) = response_capture {
1018 let (request_body, request_body_truncated) = match body {
1019 Some(b) => {
1020 let (rb, t) = truncate_body_for_capture(b);
1021 (Some(rb), t)
1022 }
1023 None => (None, false),
1024 };
1025 let (response_body, response_body_truncated) = match resp_body {
1026 Some((rb, t)) => (Some(rb), t),
1027 None => (None, false),
1028 };
1029 let entry = CaseCapture {
1030 label: label.to_string(),
1031 method: method.to_string(),
1032 url: build_query_url(url, &query),
1033 request_headers: capture_headers,
1034 request_body,
1035 request_body_truncated,
1036 response_status: actual_status,
1037 response_headers: resp_headers,
1038 response_body,
1039 response_body_truncated,
1040 error,
1041 };
1042 if let Ok(mut guard) = sink.lock() {
1043 guard.push(entry);
1044 }
1045 }
1046 CaseOutcome {
1047 label: label.to_string(),
1048 expected_4xx,
1049 actual_status,
1050 passed,
1051 }
1052}
1053
1054#[allow(clippy::too_many_arguments)]
1060async fn send_case(
1061 client: &Client,
1062 config: &SelfTestConfig,
1063 method: Method,
1064 url: &str,
1065 label: &str,
1066 expected_4xx: bool,
1067 body: Option<&str>,
1068 query: Vec<(String, String)>,
1069 headers: Vec<(String, String)>,
1070) -> CaseOutcome {
1071 send_case_with_extra(
1075 client,
1076 config,
1077 method,
1078 url,
1079 label,
1080 expected_4xx,
1081 body,
1082 query,
1083 headers,
1084 config.extra_headers.clone(),
1085 )
1086 .await
1087}
1088
1089fn build_query_url(base: &str, query: &[(String, String)]) -> String {
1095 if query.is_empty() {
1096 return base.to_string();
1097 }
1098 let qs: String = query
1099 .iter()
1100 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
1101 .collect::<Vec<_>>()
1102 .join("&");
1103 if base.contains('?') {
1104 format!("{base}&{qs}")
1105 } else {
1106 format!("{base}?{qs}")
1107 }
1108}
1109
1110#[allow(dead_code)]
1120fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
1121 build_url_with_base(target, None, path_template, path_params)
1122}
1123
1124fn build_url_with_base(
1130 target: &str,
1131 base_path: Option<&str>,
1132 path_template: &str,
1133 path_params: &[(String, String)],
1134) -> String {
1135 let mut url = path_template.to_string();
1136 for (name, value) in path_params {
1137 let placeholder = format!("{{{}}}", name);
1138 if !value.is_empty() {
1139 url = url.replace(&placeholder, value);
1140 }
1141 }
1142 let target = target.trim_end_matches('/');
1143 let prefix = match base_path {
1144 Some(bp) if !bp.is_empty() => {
1145 let trimmed = bp.trim_end_matches('/');
1146 if trimmed.starts_with('/') {
1147 trimmed.to_string()
1148 } else {
1149 format!("/{}", trimmed)
1150 }
1151 }
1152 _ => String::new(),
1153 };
1154 let path = if url.starts_with('/') {
1155 url
1156 } else {
1157 format!("/{url}")
1158 };
1159 format!("{target}{prefix}{path}")
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165
1166 fn op(
1167 method: &str,
1168 path: &str,
1169 body: Option<&str>,
1170 query: Vec<(&str, &str)>,
1171 headers: Vec<(&str, &str)>,
1172 path_params: Vec<(&str, &str)>,
1173 ) -> AnnotatedOperation {
1174 AnnotatedOperation {
1175 method: method.into(),
1176 path: path.into(),
1177 features: Vec::new(),
1178 request_body_content_type: body.map(|_| "application/json".into()),
1179 sample_body: body.map(|s| s.to_string()),
1180 query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1181 header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1182 path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1183 response_schema: None,
1184 request_body_schema: None,
1185 security_schemes: Vec::new(),
1186 }
1187 }
1188
1189 #[test]
1190 fn build_url_substitutes_path_params() {
1191 let url = build_url(
1192 "https://api.test/",
1193 "/users/{id}/posts/{pid}",
1194 &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1195 );
1196 assert_eq!(url, "https://api.test/users/42/posts/7");
1197 }
1198
1199 #[test]
1203 fn detect_target_misconfiguration_when_all_positives_share_status() {
1204 let mut report = SelfTestReport {
1205 positive_pass: 0,
1206 positive_fail: 50,
1207 ..Default::default()
1208 };
1209 for i in 0..50 {
1210 report.operations.push(OperationResult {
1211 method: "GET".into(),
1212 path: format!("/r/{i}"),
1213 positive: Some(CaseOutcome {
1214 label: "positive".into(),
1215 expected_4xx: false,
1216 actual_status: 404,
1217 passed: false,
1218 }),
1219 negatives: Vec::new(),
1220 });
1221 }
1222 assert_eq!(report.detect_target_misconfiguration(), Some(404));
1223 }
1224
1225 #[test]
1226 fn detect_target_misconfiguration_returns_none_when_some_pass() {
1227 let mut report = SelfTestReport {
1228 positive_pass: 5,
1229 positive_fail: 50,
1230 ..Default::default()
1231 };
1232 for i in 0..55 {
1233 report.operations.push(OperationResult {
1234 method: "GET".into(),
1235 path: format!("/r/{i}"),
1236 positive: Some(CaseOutcome {
1237 label: "positive".into(),
1238 expected_4xx: false,
1239 actual_status: if i < 5 { 200 } else { 404 },
1240 passed: i < 5,
1241 }),
1242 negatives: Vec::new(),
1243 });
1244 }
1245 assert_eq!(report.detect_target_misconfiguration(), None);
1246 }
1247
1248 #[test]
1253 fn build_url_applies_base_path_when_present() {
1254 let url = build_url_with_base(
1255 "https://api.example.com",
1256 Some("/api"),
1257 "/users/{id}",
1258 &[("id".into(), "42".into())],
1259 );
1260 assert_eq!(url, "https://api.example.com/api/users/42");
1261 }
1262
1263 #[test]
1267 fn build_url_normalises_base_path() {
1268 let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1269 assert_eq!(no_slash, "https://t/api/x");
1270 let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1271 assert_eq!(trailing, "https://t/api/x");
1272 let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1273 assert_eq!(empty, "https://t/x");
1274 let none = build_url_with_base("https://t", None, "/x", &[]);
1275 assert_eq!(none, "https://t/x");
1276 }
1277
1278 #[test]
1279 fn build_url_keeps_placeholders_when_no_sample() {
1280 let url = build_url("https://api.test", "/users/{id}", &[]);
1281 assert_eq!(url, "https://api.test/users/{id}");
1282 }
1283
1284 #[test]
1285 fn report_summary_calls_out_misses() {
1286 let r = SelfTestReport {
1287 positive_pass: 3,
1288 positive_fail: 0,
1289 negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1290 negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1291 operations: Vec::new(),
1292 };
1293 let summary = r.render_summary();
1294 assert!(summary.contains("Positives: 3 pass / 0 fail"));
1295 assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1296 assert!(summary.contains("⚠"));
1297 assert!(!r.all_passed());
1298 }
1299
1300 #[test]
1301 fn report_all_passed_when_no_miss() {
1302 let r = SelfTestReport {
1303 positive_pass: 5,
1304 positive_fail: 0,
1305 negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1306 negative_missed: BTreeMap::new(),
1307 operations: Vec::new(),
1308 };
1309 assert!(r.all_passed());
1310 assert!(r.render_summary().contains("✓"));
1311 }
1312
1313 #[tokio::test]
1314 async fn run_self_test_against_unreachable_target_marks_all_failed() {
1315 let cfg = SelfTestConfig {
1318 target_url: "http://127.0.0.1:1".into(),
1319 timeout: Duration::from_millis(200),
1320 ..Default::default()
1321 };
1322 let ops = vec![op(
1323 "POST",
1324 "/users",
1325 Some("{\"name\":\"a\"}"),
1326 vec![],
1327 vec![],
1328 vec![],
1329 )];
1330 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1331 assert_eq!(report.positive_fail, 1);
1335 assert!(report.negative_missed.values().sum::<usize>() >= 1);
1336 assert!(!report.all_passed());
1337 }
1338
1339 #[tokio::test]
1345 async fn schema_driven_negatives_fire_when_schema_present() {
1346 use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1347 let cfg = SelfTestConfig {
1348 target_url: "http://127.0.0.1:1".into(),
1349 timeout: Duration::from_millis(200),
1350 ..Default::default()
1351 };
1352 let mut obj = ObjectType::default();
1358 obj.properties.insert(
1359 "name".to_string(),
1360 ReferenceOr::Item(Box::new(Schema {
1361 schema_data: SchemaData::default(),
1362 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1363 })),
1364 );
1365 obj.properties.insert(
1366 "age".to_string(),
1367 ReferenceOr::Item(Box::new(Schema {
1368 schema_data: SchemaData::default(),
1369 schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1370 })),
1371 );
1372 obj.required = vec!["name".into(), "age".into()];
1373 let schema = Schema {
1374 schema_data: SchemaData::default(),
1375 schema_kind: SchemaKind::Type(Type::Object(obj)),
1376 };
1377
1378 let mut o =
1379 op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1380 o.request_body_schema = Some(schema);
1381 let report = run_self_test(&[o], &cfg).await.expect("client builds");
1382 let labels: std::collections::BTreeSet<String> = report
1384 .operations
1385 .iter()
1386 .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1387 .collect();
1388 assert!(
1389 labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1390 "missing type-mismatch negative; got {labels:?}"
1391 );
1392 assert!(
1393 labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1394 "missing required-removed negative; got {labels:?}"
1395 );
1396 assert!(
1397 labels.iter().any(|l| l == "parameters:uri-too-long"),
1398 "missing URI-length negative; got {labels:?}"
1399 );
1400 }
1401
1402 #[tokio::test]
1407 async fn no_sample_body_still_produces_request_body_negatives() {
1408 let cfg = SelfTestConfig {
1409 target_url: "http://127.0.0.1:1".into(),
1410 timeout: Duration::from_millis(200),
1411 ..Default::default()
1412 };
1413 let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1415 let mut ops_fixed = ops;
1417 ops_fixed[0].request_body_content_type = Some("application/json".into());
1418 let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1419 assert!(
1423 report.negative_missed.values().sum::<usize>() >= 2,
1424 "expected ≥2 request-body negatives, got {:?}",
1425 report.negative_missed
1426 );
1427 }
1428
1429 #[tokio::test]
1434 async fn path_param_only_endpoint_produces_a_probe() {
1435 let cfg = SelfTestConfig {
1436 target_url: "http://127.0.0.1:1".into(),
1437 timeout: Duration::from_millis(200),
1438 ..Default::default()
1439 };
1440 let ops = vec![op(
1441 "GET",
1442 "/teams/{team-id}",
1443 None,
1444 vec![],
1445 vec![],
1446 vec![("team-id", "1")],
1447 )];
1448 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1449 let total: usize = report.negative_caught.values().sum::<usize>()
1450 + report.negative_missed.values().sum::<usize>();
1451 assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1452 }
1453
1454 #[test]
1458 fn effective_op_headers_appends_geo_ip_to_default_headers() {
1459 let ip: IpAddr = "203.0.113.42".parse().unwrap();
1460 let headers = effective_op_headers(
1461 &[("Accept".into(), "application/json".into())],
1462 Some(ip),
1463 &default_geo_source_headers(),
1464 );
1465 let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1466 assert!(names.contains(&"Accept"));
1467 assert!(names.contains(&"X-Forwarded-For"));
1468 assert!(names.contains(&"True-Client-IP"));
1469 assert!(names.contains(&"CF-Connecting-IP"));
1470 let geo_values: Vec<&str> =
1472 headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1473 for v in geo_values {
1474 assert_eq!(v, "203.0.113.42");
1475 }
1476 }
1477
1478 #[test]
1482 fn effective_op_headers_respects_spec_declared_header() {
1483 let ip: IpAddr = "203.0.113.99".parse().unwrap();
1484 let headers = effective_op_headers(
1485 &[("x-forwarded-for".into(), "10.0.0.1".into())],
1486 Some(ip),
1487 &["X-Forwarded-For".to_string()],
1488 );
1489 let xff: Vec<&str> = headers
1492 .iter()
1493 .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1494 .map(|(_, v)| v.as_str())
1495 .collect();
1496 assert_eq!(xff, vec!["10.0.0.1"]);
1497 }
1498
1499 #[test]
1501 fn effective_op_headers_is_a_noop_without_geo_ip() {
1502 let base = vec![("Accept".into(), "json".into())];
1503 let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1504 assert_eq!(h1, base);
1505 let ip: IpAddr = "10.0.0.1".parse().unwrap();
1506 let h2 = effective_op_headers(&base, Some(ip), &[]);
1507 assert_eq!(h2, base);
1508 }
1509
1510 #[test]
1515 fn build_client_pool_one_per_source_ip() {
1516 let mut cfg = SelfTestConfig {
1517 target_url: "http://127.0.0.1:1".into(),
1518 timeout: Duration::from_millis(200),
1519 ..Default::default()
1520 };
1521 assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
1523 cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
1525 assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
1526 }
1527
1528 #[tokio::test]
1535 async fn run_self_test_with_geo_source_completes() {
1536 let cfg = SelfTestConfig {
1537 target_url: "http://127.0.0.1:1".into(),
1538 timeout: Duration::from_millis(200),
1539 geo_source_ips: vec![
1540 "203.0.113.1".parse().unwrap(),
1541 "203.0.113.2".parse().unwrap(),
1542 ],
1543 ..Default::default()
1544 };
1545 let ops = vec![
1546 op("GET", "/a", None, vec![], vec![], vec![]),
1547 op("GET", "/b", None, vec![], vec![], vec![]),
1548 op("GET", "/c", None, vec![], vec![], vec![]),
1549 ];
1550 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1551 assert_eq!(report.operations.len(), 3);
1552 }
1553
1554 #[tokio::test]
1563 async fn geo_headers_present_on_every_probe_with_capture() {
1564 let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
1565 let cfg = SelfTestConfig {
1566 target_url: "http://127.0.0.1:1".into(),
1567 timeout: Duration::from_millis(50),
1568 geo_source_ips: vec!["203.0.113.5".parse().unwrap()],
1569 capture: Some(sink.clone()),
1570 ..Default::default()
1571 };
1572 let ops = vec![op(
1578 "GET",
1579 "/items",
1580 Some("{}"),
1581 vec![("id", "1")],
1582 vec![("X-Trace", "x")],
1583 vec![],
1584 )];
1585 let _ = run_self_test(&ops, &cfg).await.expect("client builds");
1586 let captures = sink.lock().unwrap();
1587 assert!(!captures.is_empty(), "self-test should record probes");
1588 let geo_headers: std::collections::HashSet<&str> =
1591 ["X-Forwarded-For", "True-Client-IP", "CF-Connecting-IP"].into_iter().collect();
1592 for c in captures.iter() {
1593 let has_geo = c
1594 .request_headers
1595 .iter()
1596 .any(|(k, v)| geo_headers.contains(k.as_str()) && v == "203.0.113.5");
1597 assert!(
1598 has_geo,
1599 "probe `{}` is missing the geo IP header; got headers: {:?}",
1600 c.label, c.request_headers
1601 );
1602 }
1603 }
1604
1605 #[test]
1606 fn json_serialises_report() {
1607 let r = SelfTestReport {
1608 positive_pass: 1,
1609 positive_fail: 0,
1610 negative_caught: BTreeMap::new(),
1611 negative_missed: BTreeMap::new(),
1612 operations: vec![OperationResult {
1613 method: "GET".into(),
1614 path: "/x".into(),
1615 positive: Some(CaseOutcome {
1616 label: "positive".into(),
1617 expected_4xx: false,
1618 actual_status: 200,
1619 passed: true,
1620 }),
1621 negatives: Vec::new(),
1622 }],
1623 };
1624 let json = serde_json::to_value(&r).expect("serialises");
1625 assert_eq!(json["positive_pass"], serde_json::json!(1));
1626 assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
1627 }
1628}