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.header_params.clone(),
451 )
452 .await,
453 );
454 }
455 }
456 }
457 }
458
459 {
464 let pad = "p=".to_string() + &"x".repeat(9_000);
465 let bad_url = if url.contains('?') {
466 format!("{url}&{pad}")
467 } else {
468 format!("{url}?{pad}")
469 };
470 negatives.push(
471 send_case(
472 client,
473 config,
474 method.clone(),
475 &bad_url,
476 "parameters:uri-too-long",
477 true,
478 op.sample_body.as_deref(),
479 op.query_params.clone(),
480 op.header_params.clone(),
481 )
482 .await,
483 );
484 }
485
486 if !op.path_params.is_empty() {
500 let mut url_with_placeholder = op.path.clone();
501 if let Some((first_name, _)) = op.path_params.first() {
502 for (name, value) in op.path_params.iter().skip(1) {
505 if !value.is_empty() {
506 url_with_placeholder =
507 url_with_placeholder.replace(&format!("{{{name}}}"), value);
508 }
509 }
510 url_with_placeholder =
514 url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
515 let bad_url = build_url_with_base(
519 &config.target_url,
520 config.base_path.as_deref(),
521 &url_with_placeholder,
522 &[],
523 );
524 negatives.push(
525 send_case(
526 client,
527 config,
528 method.clone(),
529 &bad_url,
530 "parameters:bad-path-param",
531 true,
532 op.sample_body.as_deref(),
533 op.query_params.clone(),
534 op_headers.clone(),
535 )
536 .await,
537 );
538 }
539 }
540
541 if !op.query_params.is_empty() {
543 let mut q = op.query_params.clone();
544 q.remove(0);
545 negatives.push(
546 send_case(
547 client,
548 config,
549 method.clone(),
550 &url,
551 "parameters:missing-query",
552 true,
553 op.sample_body.as_deref(),
554 q,
555 op_headers.clone(),
556 )
557 .await,
558 );
559 }
560
561 for probe in build_security_probes(&op.security_schemes) {
579 let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
583 let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
584 let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
585 let mut req_headers = stripped_headers;
586 for (k, v) in &probe.headers {
587 req_headers.push((k.clone(), v.clone()));
588 }
589 let mut req_query = stripped_query;
590 for (k, v) in &probe.query {
591 req_query.push((k.clone(), v.clone()));
592 }
593 negatives.push(
594 send_case_with_extra(
595 client,
596 config,
597 method.clone(),
598 &url,
599 &probe.label,
600 true,
601 op.sample_body.as_deref(),
602 req_query,
603 req_headers,
604 stripped_extra,
605 )
606 .await,
607 );
608 }
609
610 if !op.header_params.is_empty() {
612 let mut h = op.header_params.clone();
613 h.remove(0);
614 negatives.push(
615 send_case(
616 client,
617 config,
618 method.clone(),
619 &url,
620 "parameters:missing-header",
621 true,
622 op.sample_body.as_deref(),
623 op.query_params.clone(),
624 h,
625 )
626 .await,
627 );
628 }
629
630 for probe in build_owasp_probes(op) {
650 negatives.push(
651 send_case(
652 client,
653 config,
654 method.clone(),
655 &url,
656 &probe.label,
657 true,
658 probe.body.as_deref(),
659 probe.query,
660 op.header_params.clone(),
661 )
662 .await,
663 );
664 }
665
666 OperationResult {
667 method: op.method.clone(),
668 path: op.path.clone(),
669 positive: Some(positive),
670 negatives,
671 }
672}
673
674#[derive(Debug, Clone)]
676struct OwaspProbe {
677 label: String,
678 body: Option<String>,
679 query: Vec<(String, String)>,
680}
681
682fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
686 use crate::security_payloads::{SecurityCategory, SecurityPayloads};
687
688 let categories = [
689 SecurityCategory::SqlInjection,
690 SecurityCategory::Xss,
691 SecurityCategory::CommandInjection,
692 SecurityCategory::PathTraversal,
693 SecurityCategory::Ssti,
694 SecurityCategory::LdapInjection,
695 SecurityCategory::Xxe,
696 ];
697
698 let injection_target = pick_injection_target(op);
702 let Some(target) = injection_target else {
703 return Vec::new();
704 };
705
706 let mut probes = Vec::new();
707 for cat in categories {
708 let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
713 continue;
714 };
715 let mut query = op.query_params.clone();
716 let mut body = op.sample_body.clone();
717 match &target {
718 InjectionTarget::Query(idx) => {
719 if let Some(slot) = query.get_mut(*idx) {
720 slot.1 = payload.payload.clone();
721 }
722 }
723 InjectionTarget::BodyStringField(field) => {
724 body = inject_into_body_field(body.as_deref(), field, &payload.payload);
725 }
726 }
727 probes.push(OwaspProbe {
728 label: format!("owasp:{}", cat),
729 body,
730 query,
731 });
732 }
733 probes
734}
735
736#[derive(Debug, Clone)]
737enum InjectionTarget {
738 Query(usize),
739 BodyStringField(String),
740}
741
742fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
743 if !op.query_params.is_empty() {
744 return Some(InjectionTarget::Query(0));
745 }
746 let sample = op.sample_body.as_deref()?;
747 let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
748 let obj = parsed.as_object()?;
749 for (k, v) in obj {
750 if v.is_string() {
751 return Some(InjectionTarget::BodyStringField(k.clone()));
752 }
753 }
754 None
755}
756
757fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
761 let raw = body?;
762 let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
763 let obj = parsed.as_object_mut()?;
764 obj.insert(field.to_string(), serde_json::json!(payload));
765 serde_json::to_string(&parsed).ok()
766}
767
768#[allow(clippy::too_many_arguments)]
769#[derive(Debug, Clone)]
771struct SecurityProbe {
772 label: String,
774 headers: Vec<(String, String)>,
776 query: Vec<(String, String)>,
778}
779
780fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
786 if schemes.is_empty() {
787 return Vec::new();
788 }
789 let mut probes: Vec<SecurityProbe> = Vec::new();
790 let mut seen_bearer = false;
791 let mut seen_basic = false;
792 let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
795 for s in schemes {
796 match s {
797 SecuritySchemeInfo::Bearer if !seen_bearer => {
798 seen_bearer = true;
799 probes.push(SecurityProbe {
800 label: "security:bad-bearer".into(),
801 headers: vec![(
802 "Authorization".into(),
803 "Bearer self-test-invalid-token".into(),
804 )],
805 query: Vec::new(),
806 });
807 }
808 SecuritySchemeInfo::Basic if !seen_basic => {
809 seen_basic = true;
810 probes.push(SecurityProbe {
812 label: "security:bad-basic".into(),
813 headers: vec![(
814 "Authorization".into(),
815 "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
816 )],
817 query: Vec::new(),
818 });
819 }
820 SecuritySchemeInfo::ApiKey { location, name } => {
821 let loc_tag = match location {
822 ApiKeyLocation::Header => "header",
823 ApiKeyLocation::Query => "query",
824 ApiKeyLocation::Cookie => "cookie",
825 };
826 if seen_apikey.contains(&(loc_tag, name.clone())) {
827 continue;
828 }
829 seen_apikey.insert((loc_tag, name.clone()));
830 let label = format!("security:bad-apikey:{}", name);
831 let bad = "self-test-invalid-key".to_string();
832 match location {
833 ApiKeyLocation::Header => probes.push(SecurityProbe {
834 label,
835 headers: vec![(name.clone(), bad)],
836 query: Vec::new(),
837 }),
838 ApiKeyLocation::Query => probes.push(SecurityProbe {
839 label,
840 headers: Vec::new(),
841 query: vec![(name.clone(), bad)],
842 }),
843 ApiKeyLocation::Cookie => probes.push(SecurityProbe {
844 label,
845 headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
846 query: Vec::new(),
847 }),
848 }
849 }
850 _ => {}
851 }
852 }
853 probes.push(SecurityProbe {
858 label: "security:no-auth".into(),
859 headers: Vec::new(),
860 query: Vec::new(),
861 });
862 probes
863}
864
865fn strip_auth(
869 headers: &[(String, String)],
870 schemes: &[SecuritySchemeInfo],
871) -> Vec<(String, String)> {
872 let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
873 for s in schemes {
874 if let SecuritySchemeInfo::ApiKey {
875 location: ApiKeyLocation::Header,
876 name,
877 } = s
878 {
879 apikey_headers.insert(name.to_lowercase());
880 }
881 if let SecuritySchemeInfo::ApiKey {
882 location: ApiKeyLocation::Cookie,
883 ..
884 } = s
885 {
886 apikey_headers.insert("cookie".into());
887 }
888 }
889 headers
890 .iter()
891 .filter(|(k, _)| {
892 let lk = k.to_lowercase();
893 lk != "authorization" && !apikey_headers.contains(&lk)
894 })
895 .cloned()
896 .collect()
897}
898
899fn strip_auth_query(
902 query: &[(String, String)],
903 schemes: &[SecuritySchemeInfo],
904) -> Vec<(String, String)> {
905 let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
906 for s in schemes {
907 if let SecuritySchemeInfo::ApiKey {
908 location: ApiKeyLocation::Query,
909 name,
910 } = s
911 {
912 apikey_query.insert(name.clone());
913 }
914 }
915 query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
916}
917
918#[allow(clippy::too_many_arguments)]
922async fn send_case_with_extra(
923 client: &Client,
924 config: &SelfTestConfig,
925 method: Method,
926 url: &str,
927 label: &str,
928 expected_4xx: bool,
929 body: Option<&str>,
930 query: Vec<(String, String)>,
931 headers: Vec<(String, String)>,
932 extra_headers: Vec<(String, String)>,
933) -> CaseOutcome {
934 let mut req = client.request(method.clone(), url);
935 let mut capture_headers: BTreeMap<String, String> = BTreeMap::new();
936 for (k, v) in &query {
937 req = req.query(&[(k.as_str(), v.as_str())]);
938 }
939 for (k, v) in &headers {
940 req = req.header(k, v);
941 capture_headers.insert(k.clone(), v.clone());
942 }
943 for (k, v) in &extra_headers {
944 req = req.header(k, v);
945 capture_headers.insert(k.clone(), v.clone());
946 }
947 if let Some(b) = body {
948 req = req
949 .header(reqwest::header::CONTENT_TYPE, "application/json")
950 .body(b.to_string());
951 capture_headers.insert("Content-Type".to_string(), "application/json".to_string());
952 }
953 let (actual_status, response_capture) = match req.send().await {
954 Ok(resp) => {
955 let status = resp.status().as_u16();
956 if let Some(sink) = &config.capture {
957 let resp_headers: BTreeMap<String, String> = resp
958 .headers()
959 .iter()
960 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
961 .collect();
962 let text = resp.text().await.unwrap_or_default();
963 let (rb, truncated) = truncate_body_for_capture(&text);
964 (status, Some((Some((rb, truncated)), resp_headers, None, sink.clone())))
965 } else {
966 (status, None)
967 }
968 }
969 Err(e) => {
970 let err_str = e.to_string();
971 if let Some(sink) = &config.capture {
972 (0, Some((None, BTreeMap::new(), Some(err_str), sink.clone())))
973 } else {
974 (0, None)
975 }
976 }
977 };
978 let passed = if expected_4xx {
979 (400..500).contains(&actual_status)
980 } else {
981 (200..400).contains(&actual_status)
982 };
983 if let Some((resp_body, resp_headers, error, sink)) = response_capture {
984 let (request_body, request_body_truncated) = match body {
985 Some(b) => {
986 let (rb, t) = truncate_body_for_capture(b);
987 (Some(rb), t)
988 }
989 None => (None, false),
990 };
991 let (response_body, response_body_truncated) = match resp_body {
992 Some((rb, t)) => (Some(rb), t),
993 None => (None, false),
994 };
995 let entry = CaseCapture {
996 label: label.to_string(),
997 method: method.to_string(),
998 url: build_query_url(url, &query),
999 request_headers: capture_headers,
1000 request_body,
1001 request_body_truncated,
1002 response_status: actual_status,
1003 response_headers: resp_headers,
1004 response_body,
1005 response_body_truncated,
1006 error,
1007 };
1008 if let Ok(mut guard) = sink.lock() {
1009 guard.push(entry);
1010 }
1011 }
1012 CaseOutcome {
1013 label: label.to_string(),
1014 expected_4xx,
1015 actual_status,
1016 passed,
1017 }
1018}
1019
1020#[allow(clippy::too_many_arguments)]
1026async fn send_case(
1027 client: &Client,
1028 config: &SelfTestConfig,
1029 method: Method,
1030 url: &str,
1031 label: &str,
1032 expected_4xx: bool,
1033 body: Option<&str>,
1034 query: Vec<(String, String)>,
1035 headers: Vec<(String, String)>,
1036) -> CaseOutcome {
1037 send_case_with_extra(
1041 client,
1042 config,
1043 method,
1044 url,
1045 label,
1046 expected_4xx,
1047 body,
1048 query,
1049 headers,
1050 config.extra_headers.clone(),
1051 )
1052 .await
1053}
1054
1055fn build_query_url(base: &str, query: &[(String, String)]) -> String {
1061 if query.is_empty() {
1062 return base.to_string();
1063 }
1064 let qs: String = query
1065 .iter()
1066 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
1067 .collect::<Vec<_>>()
1068 .join("&");
1069 if base.contains('?') {
1070 format!("{base}&{qs}")
1071 } else {
1072 format!("{base}?{qs}")
1073 }
1074}
1075
1076#[allow(dead_code)]
1086fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
1087 build_url_with_base(target, None, path_template, path_params)
1088}
1089
1090fn build_url_with_base(
1096 target: &str,
1097 base_path: Option<&str>,
1098 path_template: &str,
1099 path_params: &[(String, String)],
1100) -> String {
1101 let mut url = path_template.to_string();
1102 for (name, value) in path_params {
1103 let placeholder = format!("{{{}}}", name);
1104 if !value.is_empty() {
1105 url = url.replace(&placeholder, value);
1106 }
1107 }
1108 let target = target.trim_end_matches('/');
1109 let prefix = match base_path {
1110 Some(bp) if !bp.is_empty() => {
1111 let trimmed = bp.trim_end_matches('/');
1112 if trimmed.starts_with('/') {
1113 trimmed.to_string()
1114 } else {
1115 format!("/{}", trimmed)
1116 }
1117 }
1118 _ => String::new(),
1119 };
1120 let path = if url.starts_with('/') {
1121 url
1122 } else {
1123 format!("/{url}")
1124 };
1125 format!("{target}{prefix}{path}")
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131
1132 fn op(
1133 method: &str,
1134 path: &str,
1135 body: Option<&str>,
1136 query: Vec<(&str, &str)>,
1137 headers: Vec<(&str, &str)>,
1138 path_params: Vec<(&str, &str)>,
1139 ) -> AnnotatedOperation {
1140 AnnotatedOperation {
1141 method: method.into(),
1142 path: path.into(),
1143 features: Vec::new(),
1144 request_body_content_type: body.map(|_| "application/json".into()),
1145 sample_body: body.map(|s| s.to_string()),
1146 query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1147 header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1148 path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1149 response_schema: None,
1150 request_body_schema: None,
1151 security_schemes: Vec::new(),
1152 }
1153 }
1154
1155 #[test]
1156 fn build_url_substitutes_path_params() {
1157 let url = build_url(
1158 "https://api.test/",
1159 "/users/{id}/posts/{pid}",
1160 &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1161 );
1162 assert_eq!(url, "https://api.test/users/42/posts/7");
1163 }
1164
1165 #[test]
1169 fn detect_target_misconfiguration_when_all_positives_share_status() {
1170 let mut report = SelfTestReport {
1171 positive_pass: 0,
1172 positive_fail: 50,
1173 ..Default::default()
1174 };
1175 for i in 0..50 {
1176 report.operations.push(OperationResult {
1177 method: "GET".into(),
1178 path: format!("/r/{i}"),
1179 positive: Some(CaseOutcome {
1180 label: "positive".into(),
1181 expected_4xx: false,
1182 actual_status: 404,
1183 passed: false,
1184 }),
1185 negatives: Vec::new(),
1186 });
1187 }
1188 assert_eq!(report.detect_target_misconfiguration(), Some(404));
1189 }
1190
1191 #[test]
1192 fn detect_target_misconfiguration_returns_none_when_some_pass() {
1193 let mut report = SelfTestReport {
1194 positive_pass: 5,
1195 positive_fail: 50,
1196 ..Default::default()
1197 };
1198 for i in 0..55 {
1199 report.operations.push(OperationResult {
1200 method: "GET".into(),
1201 path: format!("/r/{i}"),
1202 positive: Some(CaseOutcome {
1203 label: "positive".into(),
1204 expected_4xx: false,
1205 actual_status: if i < 5 { 200 } else { 404 },
1206 passed: i < 5,
1207 }),
1208 negatives: Vec::new(),
1209 });
1210 }
1211 assert_eq!(report.detect_target_misconfiguration(), None);
1212 }
1213
1214 #[test]
1219 fn build_url_applies_base_path_when_present() {
1220 let url = build_url_with_base(
1221 "https://api.example.com",
1222 Some("/api"),
1223 "/users/{id}",
1224 &[("id".into(), "42".into())],
1225 );
1226 assert_eq!(url, "https://api.example.com/api/users/42");
1227 }
1228
1229 #[test]
1233 fn build_url_normalises_base_path() {
1234 let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1235 assert_eq!(no_slash, "https://t/api/x");
1236 let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1237 assert_eq!(trailing, "https://t/api/x");
1238 let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1239 assert_eq!(empty, "https://t/x");
1240 let none = build_url_with_base("https://t", None, "/x", &[]);
1241 assert_eq!(none, "https://t/x");
1242 }
1243
1244 #[test]
1245 fn build_url_keeps_placeholders_when_no_sample() {
1246 let url = build_url("https://api.test", "/users/{id}", &[]);
1247 assert_eq!(url, "https://api.test/users/{id}");
1248 }
1249
1250 #[test]
1251 fn report_summary_calls_out_misses() {
1252 let r = SelfTestReport {
1253 positive_pass: 3,
1254 positive_fail: 0,
1255 negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1256 negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1257 operations: Vec::new(),
1258 };
1259 let summary = r.render_summary();
1260 assert!(summary.contains("Positives: 3 pass / 0 fail"));
1261 assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1262 assert!(summary.contains("⚠"));
1263 assert!(!r.all_passed());
1264 }
1265
1266 #[test]
1267 fn report_all_passed_when_no_miss() {
1268 let r = SelfTestReport {
1269 positive_pass: 5,
1270 positive_fail: 0,
1271 negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1272 negative_missed: BTreeMap::new(),
1273 operations: Vec::new(),
1274 };
1275 assert!(r.all_passed());
1276 assert!(r.render_summary().contains("✓"));
1277 }
1278
1279 #[tokio::test]
1280 async fn run_self_test_against_unreachable_target_marks_all_failed() {
1281 let cfg = SelfTestConfig {
1284 target_url: "http://127.0.0.1:1".into(),
1285 timeout: Duration::from_millis(200),
1286 ..Default::default()
1287 };
1288 let ops = vec![op(
1289 "POST",
1290 "/users",
1291 Some("{\"name\":\"a\"}"),
1292 vec![],
1293 vec![],
1294 vec![],
1295 )];
1296 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1297 assert_eq!(report.positive_fail, 1);
1301 assert!(report.negative_missed.values().sum::<usize>() >= 1);
1302 assert!(!report.all_passed());
1303 }
1304
1305 #[tokio::test]
1311 async fn schema_driven_negatives_fire_when_schema_present() {
1312 use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1313 let cfg = SelfTestConfig {
1314 target_url: "http://127.0.0.1:1".into(),
1315 timeout: Duration::from_millis(200),
1316 ..Default::default()
1317 };
1318 let mut obj = ObjectType::default();
1324 obj.properties.insert(
1325 "name".to_string(),
1326 ReferenceOr::Item(Box::new(Schema {
1327 schema_data: SchemaData::default(),
1328 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1329 })),
1330 );
1331 obj.properties.insert(
1332 "age".to_string(),
1333 ReferenceOr::Item(Box::new(Schema {
1334 schema_data: SchemaData::default(),
1335 schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1336 })),
1337 );
1338 obj.required = vec!["name".into(), "age".into()];
1339 let schema = Schema {
1340 schema_data: SchemaData::default(),
1341 schema_kind: SchemaKind::Type(Type::Object(obj)),
1342 };
1343
1344 let mut o =
1345 op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1346 o.request_body_schema = Some(schema);
1347 let report = run_self_test(&[o], &cfg).await.expect("client builds");
1348 let labels: std::collections::BTreeSet<String> = report
1350 .operations
1351 .iter()
1352 .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1353 .collect();
1354 assert!(
1355 labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1356 "missing type-mismatch negative; got {labels:?}"
1357 );
1358 assert!(
1359 labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1360 "missing required-removed negative; got {labels:?}"
1361 );
1362 assert!(
1363 labels.iter().any(|l| l == "parameters:uri-too-long"),
1364 "missing URI-length negative; got {labels:?}"
1365 );
1366 }
1367
1368 #[tokio::test]
1373 async fn no_sample_body_still_produces_request_body_negatives() {
1374 let cfg = SelfTestConfig {
1375 target_url: "http://127.0.0.1:1".into(),
1376 timeout: Duration::from_millis(200),
1377 ..Default::default()
1378 };
1379 let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1381 let mut ops_fixed = ops;
1383 ops_fixed[0].request_body_content_type = Some("application/json".into());
1384 let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1385 assert!(
1389 report.negative_missed.values().sum::<usize>() >= 2,
1390 "expected ≥2 request-body negatives, got {:?}",
1391 report.negative_missed
1392 );
1393 }
1394
1395 #[tokio::test]
1400 async fn path_param_only_endpoint_produces_a_probe() {
1401 let cfg = SelfTestConfig {
1402 target_url: "http://127.0.0.1:1".into(),
1403 timeout: Duration::from_millis(200),
1404 ..Default::default()
1405 };
1406 let ops = vec![op(
1407 "GET",
1408 "/teams/{team-id}",
1409 None,
1410 vec![],
1411 vec![],
1412 vec![("team-id", "1")],
1413 )];
1414 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1415 let total: usize = report.negative_caught.values().sum::<usize>()
1416 + report.negative_missed.values().sum::<usize>();
1417 assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1418 }
1419
1420 #[test]
1424 fn effective_op_headers_appends_geo_ip_to_default_headers() {
1425 let ip: IpAddr = "203.0.113.42".parse().unwrap();
1426 let headers = effective_op_headers(
1427 &[("Accept".into(), "application/json".into())],
1428 Some(ip),
1429 &default_geo_source_headers(),
1430 );
1431 let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1432 assert!(names.contains(&"Accept"));
1433 assert!(names.contains(&"X-Forwarded-For"));
1434 assert!(names.contains(&"True-Client-IP"));
1435 assert!(names.contains(&"CF-Connecting-IP"));
1436 let geo_values: Vec<&str> =
1438 headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1439 for v in geo_values {
1440 assert_eq!(v, "203.0.113.42");
1441 }
1442 }
1443
1444 #[test]
1448 fn effective_op_headers_respects_spec_declared_header() {
1449 let ip: IpAddr = "203.0.113.99".parse().unwrap();
1450 let headers = effective_op_headers(
1451 &[("x-forwarded-for".into(), "10.0.0.1".into())],
1452 Some(ip),
1453 &["X-Forwarded-For".to_string()],
1454 );
1455 let xff: Vec<&str> = headers
1458 .iter()
1459 .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1460 .map(|(_, v)| v.as_str())
1461 .collect();
1462 assert_eq!(xff, vec!["10.0.0.1"]);
1463 }
1464
1465 #[test]
1467 fn effective_op_headers_is_a_noop_without_geo_ip() {
1468 let base = vec![("Accept".into(), "json".into())];
1469 let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1470 assert_eq!(h1, base);
1471 let ip: IpAddr = "10.0.0.1".parse().unwrap();
1472 let h2 = effective_op_headers(&base, Some(ip), &[]);
1473 assert_eq!(h2, base);
1474 }
1475
1476 #[test]
1481 fn build_client_pool_one_per_source_ip() {
1482 let mut cfg = SelfTestConfig {
1483 target_url: "http://127.0.0.1:1".into(),
1484 timeout: Duration::from_millis(200),
1485 ..Default::default()
1486 };
1487 assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
1489 cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
1491 assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
1492 }
1493
1494 #[tokio::test]
1501 async fn run_self_test_with_geo_source_completes() {
1502 let cfg = SelfTestConfig {
1503 target_url: "http://127.0.0.1:1".into(),
1504 timeout: Duration::from_millis(200),
1505 geo_source_ips: vec![
1506 "203.0.113.1".parse().unwrap(),
1507 "203.0.113.2".parse().unwrap(),
1508 ],
1509 ..Default::default()
1510 };
1511 let ops = vec![
1512 op("GET", "/a", None, vec![], vec![], vec![]),
1513 op("GET", "/b", None, vec![], vec![], vec![]),
1514 op("GET", "/c", None, vec![], vec![], vec![]),
1515 ];
1516 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1517 assert_eq!(report.operations.len(), 3);
1518 }
1519
1520 #[test]
1521 fn json_serialises_report() {
1522 let r = SelfTestReport {
1523 positive_pass: 1,
1524 positive_fail: 0,
1525 negative_caught: BTreeMap::new(),
1526 negative_missed: BTreeMap::new(),
1527 operations: vec![OperationResult {
1528 method: "GET".into(),
1529 path: "/x".into(),
1530 positive: Some(CaseOutcome {
1531 label: "positive".into(),
1532 expected_4xx: false,
1533 actual_status: 200,
1534 passed: true,
1535 }),
1536 negatives: Vec::new(),
1537 }],
1538 };
1539 let json = serde_json::to_value(&r).expect("serialises");
1540 assert_eq!(json["positive_pass"], serde_json::json!(1));
1541 assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
1542 }
1543}