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::time::Duration;
27
28const SCHEMA_MUTATION_CAP: usize = 12;
35
36#[derive(Debug, Clone)]
38pub struct SelfTestConfig {
39 pub target_url: String,
40 pub skip_tls_verify: bool,
41 pub timeout: Duration,
42 pub extra_headers: Vec<(String, String)>,
44 pub delay_between_requests: Duration,
46 pub base_path: Option<String>,
53 pub source_ips: Vec<IpAddr>,
57 pub geo_source_ips: Vec<IpAddr>,
61 pub geo_source_headers: Vec<String>,
65}
66
67impl Default for SelfTestConfig {
68 fn default() -> Self {
69 Self {
70 target_url: "http://localhost:3000".into(),
71 skip_tls_verify: false,
72 timeout: Duration::from_secs(15),
73 extra_headers: Vec::new(),
74 delay_between_requests: Duration::from_millis(0),
75 base_path: None,
76 source_ips: Vec::new(),
77 geo_source_ips: Vec::new(),
78 geo_source_headers: default_geo_source_headers(),
79 }
80 }
81}
82
83pub fn default_geo_source_headers() -> Vec<String> {
90 vec![
91 "X-Forwarded-For".to_string(),
92 "True-Client-IP".to_string(),
93 "CF-Connecting-IP".to_string(),
94 ]
95}
96
97#[derive(Debug, Clone, serde::Serialize)]
99pub struct CaseOutcome {
100 pub label: String,
101 pub expected_4xx: bool,
102 pub actual_status: u16,
103 pub passed: bool,
106}
107
108#[derive(Debug, Clone, serde::Serialize)]
110pub struct OperationResult {
111 pub method: String,
112 pub path: String,
113 pub positive: Option<CaseOutcome>,
114 pub negatives: Vec<CaseOutcome>,
115}
116
117#[derive(Debug, Default, Clone, serde::Serialize)]
119pub struct SelfTestReport {
120 pub positive_pass: usize,
121 pub positive_fail: usize,
122 pub negative_caught: BTreeMap<String, usize>,
125 pub negative_missed: BTreeMap<String, usize>,
128 pub operations: Vec<OperationResult>,
129}
130
131impl SelfTestReport {
132 pub fn all_passed(&self) -> bool {
135 self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
136 }
137
138 pub fn detect_target_misconfiguration(&self) -> Option<u16> {
147 if self.positive_pass > 0 || self.positive_fail < 10 {
148 return None;
149 }
150 let mut seen: Option<u16> = None;
151 for op in &self.operations {
152 let Some(p) = &op.positive else {
153 continue;
154 };
155 if p.passed {
156 return None;
157 }
158 match seen {
159 None => seen = Some(p.actual_status),
160 Some(s) if s != p.actual_status => return None,
161 _ => {}
162 }
163 }
164 seen
165 }
166
167 pub fn render_summary(&self) -> String {
171 let mut out = String::new();
172 out.push_str(&format!(
173 "Positives: {} pass / {} fail\n",
174 self.positive_pass, self.positive_fail
175 ));
176 let mut keys: Vec<&String> =
177 self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
178 keys.sort();
179 keys.dedup();
180 for cat in keys {
181 let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
182 let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
183 let mark = if missed == 0 { "✓" } else { "⚠" };
184 out.push_str(&format!(
185 "Negatives [{}]: {} caught / {} missed {}\n",
186 cat, caught, missed, mark
187 ));
188 }
189 out
190 }
191}
192
193pub async fn run_self_test(
198 operations: &[AnnotatedOperation],
199 config: &SelfTestConfig,
200) -> Result<SelfTestReport, reqwest::Error> {
201 let clients = build_client_pool(config)?;
206 let client_cursor = AtomicUsize::new(0);
207 let geo_cursor = AtomicUsize::new(0);
208
209 let mut report = SelfTestReport::default();
210 for op in operations {
211 let client_idx = client_cursor.fetch_add(1, Ordering::Relaxed) % clients.len();
212 let client = &clients[client_idx];
213 let geo_ip = if config.geo_source_ips.is_empty() {
214 None
215 } else {
216 let idx = geo_cursor.fetch_add(1, Ordering::Relaxed) % config.geo_source_ips.len();
217 Some(config.geo_source_ips[idx])
218 };
219 let result = test_operation(client, config, op, geo_ip).await;
220 if let Some(p) = &result.positive {
221 if p.passed {
222 report.positive_pass += 1;
223 } else {
224 report.positive_fail += 1;
225 }
226 }
227 for neg in &result.negatives {
228 let cat = neg.label.split(':').next().unwrap_or("other").to_string();
229 if neg.passed {
230 *report.negative_caught.entry(cat).or_insert(0) += 1;
231 } else {
232 *report.negative_missed.entry(cat).or_insert(0) += 1;
233 }
234 }
235 report.operations.push(result);
236 if !config.delay_between_requests.is_zero() {
237 tokio::time::sleep(config.delay_between_requests).await;
238 }
239 }
240 Ok(report)
241}
242
243fn effective_op_headers(
251 base: &[(String, String)],
252 geo_ip: Option<IpAddr>,
253 geo_headers: &[String],
254) -> Vec<(String, String)> {
255 let mut out = base.to_vec();
256 let Some(ip) = geo_ip else {
257 return out;
258 };
259 let value = ip.to_string();
260 for h in geo_headers {
261 if out.iter().any(|(k, _)| k.eq_ignore_ascii_case(h)) {
264 continue;
265 }
266 out.push((h.clone(), value.clone()));
267 }
268 out
269}
270
271fn build_client_pool(config: &SelfTestConfig) -> Result<Vec<Client>, reqwest::Error> {
279 let make = |bind: Option<IpAddr>| -> Result<Client, reqwest::Error> {
280 let mut builder = Client::builder().timeout(config.timeout);
281 if config.skip_tls_verify {
282 builder = builder.danger_accept_invalid_certs(true);
283 }
284 if let Some(addr) = bind {
285 builder = builder.local_address(addr);
286 }
287 builder.build()
288 };
289 if config.source_ips.is_empty() {
290 Ok(vec![make(None)?])
291 } else {
292 config.source_ips.iter().map(|ip| make(Some(*ip))).collect()
293 }
294}
295
296async fn test_operation(
297 client: &Client,
298 config: &SelfTestConfig,
299 op: &AnnotatedOperation,
300 geo_ip: Option<IpAddr>,
301) -> OperationResult {
302 let url = build_url_with_base(
303 &config.target_url,
304 config.base_path.as_deref(),
305 &op.path,
306 &op.path_params,
307 );
308 let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
309
310 let op_headers = effective_op_headers(&op.header_params, geo_ip, &config.geo_source_headers);
315
316 let positive = send_case(
318 client,
319 config,
320 method.clone(),
321 &url,
322 "positive",
323 false,
324 op.sample_body.as_deref(),
325 op.query_params.clone(),
326 op_headers.clone(),
327 )
328 .await;
329
330 let mut negatives = Vec::new();
332
333 if op.request_body_content_type.is_some() {
342 negatives.push(
343 send_case(
344 client,
345 config,
346 method.clone(),
347 &url,
348 "request-body:empty",
349 true,
350 Some("{}"),
351 op.query_params.clone(),
352 op_headers.clone(),
353 )
354 .await,
355 );
356
357 negatives.push(
361 send_case(
362 client,
363 config,
364 method.clone(),
365 &url,
366 "request-body:wrong-type",
367 true,
368 Some("[]"),
369 op.query_params.clone(),
370 op_headers.clone(),
371 )
372 .await,
373 );
374
375 if let (Some(sample_str), Some(schema)) =
384 (op.sample_body.as_deref(), op.request_body_schema.as_ref())
385 {
386 if let Ok(sample) = serde_json::from_str::<serde_json::Value>(sample_str) {
387 let mutations = super::schema_mutator::mutate_body(&sample, schema);
388 for m in mutations.into_iter().take(SCHEMA_MUTATION_CAP) {
389 let body_str = serde_json::to_string(&m.body).unwrap_or_default();
390 negatives.push(
391 send_case(
392 client,
393 config,
394 method.clone(),
395 &url,
396 &m.label,
397 true,
398 Some(&body_str),
399 op.query_params.clone(),
400 op.header_params.clone(),
401 )
402 .await,
403 );
404 }
405 }
406 }
407 }
408
409 {
414 let pad = "p=".to_string() + &"x".repeat(9_000);
415 let bad_url = if url.contains('?') {
416 format!("{url}&{pad}")
417 } else {
418 format!("{url}?{pad}")
419 };
420 negatives.push(
421 send_case(
422 client,
423 config,
424 method.clone(),
425 &bad_url,
426 "parameters:uri-too-long",
427 true,
428 op.sample_body.as_deref(),
429 op.query_params.clone(),
430 op.header_params.clone(),
431 )
432 .await,
433 );
434 }
435
436 if !op.path_params.is_empty() {
450 let mut url_with_placeholder = op.path.clone();
451 if let Some((first_name, _)) = op.path_params.first() {
452 for (name, value) in op.path_params.iter().skip(1) {
455 if !value.is_empty() {
456 url_with_placeholder =
457 url_with_placeholder.replace(&format!("{{{name}}}"), value);
458 }
459 }
460 url_with_placeholder =
464 url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
465 let bad_url = build_url_with_base(
469 &config.target_url,
470 config.base_path.as_deref(),
471 &url_with_placeholder,
472 &[],
473 );
474 negatives.push(
475 send_case(
476 client,
477 config,
478 method.clone(),
479 &bad_url,
480 "parameters:bad-path-param",
481 true,
482 op.sample_body.as_deref(),
483 op.query_params.clone(),
484 op_headers.clone(),
485 )
486 .await,
487 );
488 }
489 }
490
491 if !op.query_params.is_empty() {
493 let mut q = op.query_params.clone();
494 q.remove(0);
495 negatives.push(
496 send_case(
497 client,
498 config,
499 method.clone(),
500 &url,
501 "parameters:missing-query",
502 true,
503 op.sample_body.as_deref(),
504 q,
505 op_headers.clone(),
506 )
507 .await,
508 );
509 }
510
511 for probe in build_security_probes(&op.security_schemes) {
529 let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
533 let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
534 let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
535 let mut req_headers = stripped_headers;
536 for (k, v) in &probe.headers {
537 req_headers.push((k.clone(), v.clone()));
538 }
539 let mut req_query = stripped_query;
540 for (k, v) in &probe.query {
541 req_query.push((k.clone(), v.clone()));
542 }
543 negatives.push(
544 send_case_with_extra(
545 client,
546 config,
547 method.clone(),
548 &url,
549 &probe.label,
550 true,
551 op.sample_body.as_deref(),
552 req_query,
553 req_headers,
554 stripped_extra,
555 )
556 .await,
557 );
558 }
559
560 if !op.header_params.is_empty() {
562 let mut h = op.header_params.clone();
563 h.remove(0);
564 negatives.push(
565 send_case(
566 client,
567 config,
568 method.clone(),
569 &url,
570 "parameters:missing-header",
571 true,
572 op.sample_body.as_deref(),
573 op.query_params.clone(),
574 h,
575 )
576 .await,
577 );
578 }
579
580 for probe in build_owasp_probes(op) {
600 negatives.push(
601 send_case(
602 client,
603 config,
604 method.clone(),
605 &url,
606 &probe.label,
607 true,
608 probe.body.as_deref(),
609 probe.query,
610 op.header_params.clone(),
611 )
612 .await,
613 );
614 }
615
616 OperationResult {
617 method: op.method.clone(),
618 path: op.path.clone(),
619 positive: Some(positive),
620 negatives,
621 }
622}
623
624#[derive(Debug, Clone)]
626struct OwaspProbe {
627 label: String,
628 body: Option<String>,
629 query: Vec<(String, String)>,
630}
631
632fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
636 use crate::security_payloads::{SecurityCategory, SecurityPayloads};
637
638 let categories = [
639 SecurityCategory::SqlInjection,
640 SecurityCategory::Xss,
641 SecurityCategory::CommandInjection,
642 SecurityCategory::PathTraversal,
643 SecurityCategory::Ssti,
644 SecurityCategory::LdapInjection,
645 SecurityCategory::Xxe,
646 ];
647
648 let injection_target = pick_injection_target(op);
652 let Some(target) = injection_target else {
653 return Vec::new();
654 };
655
656 let mut probes = Vec::new();
657 for cat in categories {
658 let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
663 continue;
664 };
665 let mut query = op.query_params.clone();
666 let mut body = op.sample_body.clone();
667 match &target {
668 InjectionTarget::Query(idx) => {
669 if let Some(slot) = query.get_mut(*idx) {
670 slot.1 = payload.payload.clone();
671 }
672 }
673 InjectionTarget::BodyStringField(field) => {
674 body = inject_into_body_field(body.as_deref(), field, &payload.payload);
675 }
676 }
677 probes.push(OwaspProbe {
678 label: format!("owasp:{}", cat),
679 body,
680 query,
681 });
682 }
683 probes
684}
685
686#[derive(Debug, Clone)]
687enum InjectionTarget {
688 Query(usize),
689 BodyStringField(String),
690}
691
692fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
693 if !op.query_params.is_empty() {
694 return Some(InjectionTarget::Query(0));
695 }
696 let sample = op.sample_body.as_deref()?;
697 let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
698 let obj = parsed.as_object()?;
699 for (k, v) in obj {
700 if v.is_string() {
701 return Some(InjectionTarget::BodyStringField(k.clone()));
702 }
703 }
704 None
705}
706
707fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
711 let raw = body?;
712 let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
713 let obj = parsed.as_object_mut()?;
714 obj.insert(field.to_string(), serde_json::json!(payload));
715 serde_json::to_string(&parsed).ok()
716}
717
718#[allow(clippy::too_many_arguments)]
719#[derive(Debug, Clone)]
721struct SecurityProbe {
722 label: String,
724 headers: Vec<(String, String)>,
726 query: Vec<(String, String)>,
728}
729
730fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
736 if schemes.is_empty() {
737 return Vec::new();
738 }
739 let mut probes: Vec<SecurityProbe> = Vec::new();
740 let mut seen_bearer = false;
741 let mut seen_basic = false;
742 let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
745 for s in schemes {
746 match s {
747 SecuritySchemeInfo::Bearer if !seen_bearer => {
748 seen_bearer = true;
749 probes.push(SecurityProbe {
750 label: "security:bad-bearer".into(),
751 headers: vec![(
752 "Authorization".into(),
753 "Bearer self-test-invalid-token".into(),
754 )],
755 query: Vec::new(),
756 });
757 }
758 SecuritySchemeInfo::Basic if !seen_basic => {
759 seen_basic = true;
760 probes.push(SecurityProbe {
762 label: "security:bad-basic".into(),
763 headers: vec![(
764 "Authorization".into(),
765 "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
766 )],
767 query: Vec::new(),
768 });
769 }
770 SecuritySchemeInfo::ApiKey { location, name } => {
771 let loc_tag = match location {
772 ApiKeyLocation::Header => "header",
773 ApiKeyLocation::Query => "query",
774 ApiKeyLocation::Cookie => "cookie",
775 };
776 if seen_apikey.contains(&(loc_tag, name.clone())) {
777 continue;
778 }
779 seen_apikey.insert((loc_tag, name.clone()));
780 let label = format!("security:bad-apikey:{}", name);
781 let bad = "self-test-invalid-key".to_string();
782 match location {
783 ApiKeyLocation::Header => probes.push(SecurityProbe {
784 label,
785 headers: vec![(name.clone(), bad)],
786 query: Vec::new(),
787 }),
788 ApiKeyLocation::Query => probes.push(SecurityProbe {
789 label,
790 headers: Vec::new(),
791 query: vec![(name.clone(), bad)],
792 }),
793 ApiKeyLocation::Cookie => probes.push(SecurityProbe {
794 label,
795 headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
796 query: Vec::new(),
797 }),
798 }
799 }
800 _ => {}
801 }
802 }
803 probes.push(SecurityProbe {
808 label: "security:no-auth".into(),
809 headers: Vec::new(),
810 query: Vec::new(),
811 });
812 probes
813}
814
815fn strip_auth(
819 headers: &[(String, String)],
820 schemes: &[SecuritySchemeInfo],
821) -> Vec<(String, String)> {
822 let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
823 for s in schemes {
824 if let SecuritySchemeInfo::ApiKey {
825 location: ApiKeyLocation::Header,
826 name,
827 } = s
828 {
829 apikey_headers.insert(name.to_lowercase());
830 }
831 if let SecuritySchemeInfo::ApiKey {
832 location: ApiKeyLocation::Cookie,
833 ..
834 } = s
835 {
836 apikey_headers.insert("cookie".into());
837 }
838 }
839 headers
840 .iter()
841 .filter(|(k, _)| {
842 let lk = k.to_lowercase();
843 lk != "authorization" && !apikey_headers.contains(&lk)
844 })
845 .cloned()
846 .collect()
847}
848
849fn strip_auth_query(
852 query: &[(String, String)],
853 schemes: &[SecuritySchemeInfo],
854) -> Vec<(String, String)> {
855 let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
856 for s in schemes {
857 if let SecuritySchemeInfo::ApiKey {
858 location: ApiKeyLocation::Query,
859 name,
860 } = s
861 {
862 apikey_query.insert(name.clone());
863 }
864 }
865 query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
866}
867
868#[allow(clippy::too_many_arguments)]
872async fn send_case_with_extra(
873 client: &Client,
874 config: &SelfTestConfig,
875 method: Method,
876 url: &str,
877 label: &str,
878 expected_4xx: bool,
879 body: Option<&str>,
880 query: Vec<(String, String)>,
881 headers: Vec<(String, String)>,
882 extra_headers: Vec<(String, String)>,
883) -> CaseOutcome {
884 let _ = config; let mut req = client.request(method, url);
886 for (k, v) in &query {
887 req = req.query(&[(k.as_str(), v.as_str())]);
888 }
889 for (k, v) in &headers {
890 req = req.header(k, v);
891 }
892 for (k, v) in &extra_headers {
893 req = req.header(k, v);
894 }
895 if let Some(b) = body {
896 req = req
897 .header(reqwest::header::CONTENT_TYPE, "application/json")
898 .body(b.to_string());
899 }
900 let actual_status = match req.send().await {
901 Ok(resp) => resp.status().as_u16(),
902 Err(_) => 0,
903 };
904 let passed = if expected_4xx {
905 (400..500).contains(&actual_status)
906 } else {
907 (200..400).contains(&actual_status)
908 };
909 CaseOutcome {
910 label: label.to_string(),
911 expected_4xx,
912 actual_status,
913 passed,
914 }
915}
916
917async fn send_case(
918 client: &Client,
919 config: &SelfTestConfig,
920 method: Method,
921 url: &str,
922 label: &str,
923 expected_4xx: bool,
924 body: Option<&str>,
925 query: Vec<(String, String)>,
926 headers: Vec<(String, String)>,
927) -> CaseOutcome {
928 let mut req = client.request(method, url);
929 for (k, v) in &query {
930 req = req.query(&[(k.as_str(), v.as_str())]);
931 }
932 for (k, v) in &headers {
933 req = req.header(k, v);
934 }
935 for (k, v) in &config.extra_headers {
936 req = req.header(k, v);
937 }
938 if let Some(b) = body {
939 req = req
940 .header(reqwest::header::CONTENT_TYPE, "application/json")
941 .body(b.to_string());
942 }
943
944 let actual_status = match req.send().await {
945 Ok(resp) => resp.status().as_u16(),
946 Err(e) if e.is_timeout() => 0,
947 Err(_) => 0,
948 };
949
950 let passed = if expected_4xx {
951 (400..500).contains(&actual_status)
952 } else {
953 (200..400).contains(&actual_status)
954 };
955
956 CaseOutcome {
957 label: label.to_string(),
958 expected_4xx,
959 actual_status,
960 passed,
961 }
962}
963
964fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
970 build_url_with_base(target, None, path_template, path_params)
971}
972
973fn build_url_with_base(
979 target: &str,
980 base_path: Option<&str>,
981 path_template: &str,
982 path_params: &[(String, String)],
983) -> String {
984 let mut url = path_template.to_string();
985 for (name, value) in path_params {
986 let placeholder = format!("{{{}}}", name);
987 if !value.is_empty() {
988 url = url.replace(&placeholder, value);
989 }
990 }
991 let target = target.trim_end_matches('/');
992 let prefix = match base_path {
993 Some(bp) if !bp.is_empty() => {
994 let trimmed = bp.trim_end_matches('/');
995 if trimmed.starts_with('/') {
996 trimmed.to_string()
997 } else {
998 format!("/{}", trimmed)
999 }
1000 }
1001 _ => String::new(),
1002 };
1003 let path = if url.starts_with('/') {
1004 url
1005 } else {
1006 format!("/{url}")
1007 };
1008 format!("{target}{prefix}{path}")
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014
1015 fn op(
1016 method: &str,
1017 path: &str,
1018 body: Option<&str>,
1019 query: Vec<(&str, &str)>,
1020 headers: Vec<(&str, &str)>,
1021 path_params: Vec<(&str, &str)>,
1022 ) -> AnnotatedOperation {
1023 AnnotatedOperation {
1024 method: method.into(),
1025 path: path.into(),
1026 features: Vec::new(),
1027 request_body_content_type: body.map(|_| "application/json".into()),
1028 sample_body: body.map(|s| s.to_string()),
1029 query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1030 header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1031 path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1032 response_schema: None,
1033 request_body_schema: None,
1034 security_schemes: Vec::new(),
1035 }
1036 }
1037
1038 #[test]
1039 fn build_url_substitutes_path_params() {
1040 let url = build_url(
1041 "https://api.test/",
1042 "/users/{id}/posts/{pid}",
1043 &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1044 );
1045 assert_eq!(url, "https://api.test/users/42/posts/7");
1046 }
1047
1048 #[test]
1052 fn detect_target_misconfiguration_when_all_positives_share_status() {
1053 let mut report = SelfTestReport {
1054 positive_pass: 0,
1055 positive_fail: 50,
1056 ..Default::default()
1057 };
1058 for i in 0..50 {
1059 report.operations.push(OperationResult {
1060 method: "GET".into(),
1061 path: format!("/r/{i}"),
1062 positive: Some(CaseOutcome {
1063 label: "positive".into(),
1064 expected_4xx: false,
1065 actual_status: 404,
1066 passed: false,
1067 }),
1068 negatives: Vec::new(),
1069 });
1070 }
1071 assert_eq!(report.detect_target_misconfiguration(), Some(404));
1072 }
1073
1074 #[test]
1075 fn detect_target_misconfiguration_returns_none_when_some_pass() {
1076 let mut report = SelfTestReport {
1077 positive_pass: 5,
1078 positive_fail: 50,
1079 ..Default::default()
1080 };
1081 for i in 0..55 {
1082 report.operations.push(OperationResult {
1083 method: "GET".into(),
1084 path: format!("/r/{i}"),
1085 positive: Some(CaseOutcome {
1086 label: "positive".into(),
1087 expected_4xx: false,
1088 actual_status: if i < 5 { 200 } else { 404 },
1089 passed: i < 5,
1090 }),
1091 negatives: Vec::new(),
1092 });
1093 }
1094 assert_eq!(report.detect_target_misconfiguration(), None);
1095 }
1096
1097 #[test]
1102 fn build_url_applies_base_path_when_present() {
1103 let url = build_url_with_base(
1104 "https://api.example.com",
1105 Some("/api"),
1106 "/users/{id}",
1107 &[("id".into(), "42".into())],
1108 );
1109 assert_eq!(url, "https://api.example.com/api/users/42");
1110 }
1111
1112 #[test]
1116 fn build_url_normalises_base_path() {
1117 let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1118 assert_eq!(no_slash, "https://t/api/x");
1119 let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1120 assert_eq!(trailing, "https://t/api/x");
1121 let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1122 assert_eq!(empty, "https://t/x");
1123 let none = build_url_with_base("https://t", None, "/x", &[]);
1124 assert_eq!(none, "https://t/x");
1125 }
1126
1127 #[test]
1128 fn build_url_keeps_placeholders_when_no_sample() {
1129 let url = build_url("https://api.test", "/users/{id}", &[]);
1130 assert_eq!(url, "https://api.test/users/{id}");
1131 }
1132
1133 #[test]
1134 fn report_summary_calls_out_misses() {
1135 let r = SelfTestReport {
1136 positive_pass: 3,
1137 positive_fail: 0,
1138 negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1139 negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1140 operations: Vec::new(),
1141 };
1142 let summary = r.render_summary();
1143 assert!(summary.contains("Positives: 3 pass / 0 fail"));
1144 assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1145 assert!(summary.contains("⚠"));
1146 assert!(!r.all_passed());
1147 }
1148
1149 #[test]
1150 fn report_all_passed_when_no_miss() {
1151 let r = SelfTestReport {
1152 positive_pass: 5,
1153 positive_fail: 0,
1154 negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1155 negative_missed: BTreeMap::new(),
1156 operations: Vec::new(),
1157 };
1158 assert!(r.all_passed());
1159 assert!(r.render_summary().contains("✓"));
1160 }
1161
1162 #[tokio::test]
1163 async fn run_self_test_against_unreachable_target_marks_all_failed() {
1164 let cfg = SelfTestConfig {
1167 target_url: "http://127.0.0.1:1".into(),
1168 timeout: Duration::from_millis(200),
1169 ..Default::default()
1170 };
1171 let ops = vec![op(
1172 "POST",
1173 "/users",
1174 Some("{\"name\":\"a\"}"),
1175 vec![],
1176 vec![],
1177 vec![],
1178 )];
1179 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1180 assert_eq!(report.positive_fail, 1);
1184 assert!(report.negative_missed.values().sum::<usize>() >= 1);
1185 assert!(!report.all_passed());
1186 }
1187
1188 #[tokio::test]
1194 async fn schema_driven_negatives_fire_when_schema_present() {
1195 use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1196 let cfg = SelfTestConfig {
1197 target_url: "http://127.0.0.1:1".into(),
1198 timeout: Duration::from_millis(200),
1199 ..Default::default()
1200 };
1201 let mut obj = ObjectType::default();
1207 obj.properties.insert(
1208 "name".to_string(),
1209 ReferenceOr::Item(Box::new(Schema {
1210 schema_data: SchemaData::default(),
1211 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1212 })),
1213 );
1214 obj.properties.insert(
1215 "age".to_string(),
1216 ReferenceOr::Item(Box::new(Schema {
1217 schema_data: SchemaData::default(),
1218 schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1219 })),
1220 );
1221 obj.required = vec!["name".into(), "age".into()];
1222 let schema = Schema {
1223 schema_data: SchemaData::default(),
1224 schema_kind: SchemaKind::Type(Type::Object(obj)),
1225 };
1226
1227 let mut o =
1228 op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1229 o.request_body_schema = Some(schema);
1230 let report = run_self_test(&[o], &cfg).await.expect("client builds");
1231 let labels: std::collections::BTreeSet<String> = report
1233 .operations
1234 .iter()
1235 .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1236 .collect();
1237 assert!(
1238 labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1239 "missing type-mismatch negative; got {labels:?}"
1240 );
1241 assert!(
1242 labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1243 "missing required-removed negative; got {labels:?}"
1244 );
1245 assert!(
1246 labels.iter().any(|l| l == "parameters:uri-too-long"),
1247 "missing URI-length negative; got {labels:?}"
1248 );
1249 }
1250
1251 #[tokio::test]
1256 async fn no_sample_body_still_produces_request_body_negatives() {
1257 let cfg = SelfTestConfig {
1258 target_url: "http://127.0.0.1:1".into(),
1259 timeout: Duration::from_millis(200),
1260 ..Default::default()
1261 };
1262 let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1264 let mut ops_fixed = ops;
1266 ops_fixed[0].request_body_content_type = Some("application/json".into());
1267 let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1268 assert!(
1272 report.negative_missed.values().sum::<usize>() >= 2,
1273 "expected ≥2 request-body negatives, got {:?}",
1274 report.negative_missed
1275 );
1276 }
1277
1278 #[tokio::test]
1283 async fn path_param_only_endpoint_produces_a_probe() {
1284 let cfg = SelfTestConfig {
1285 target_url: "http://127.0.0.1:1".into(),
1286 timeout: Duration::from_millis(200),
1287 ..Default::default()
1288 };
1289 let ops = vec![op(
1290 "GET",
1291 "/teams/{team-id}",
1292 None,
1293 vec![],
1294 vec![],
1295 vec![("team-id", "1")],
1296 )];
1297 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1298 let total: usize = report.negative_caught.values().sum::<usize>()
1299 + report.negative_missed.values().sum::<usize>();
1300 assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1301 }
1302
1303 #[test]
1307 fn effective_op_headers_appends_geo_ip_to_default_headers() {
1308 let ip: IpAddr = "203.0.113.42".parse().unwrap();
1309 let headers = effective_op_headers(
1310 &[("Accept".into(), "application/json".into())],
1311 Some(ip),
1312 &default_geo_source_headers(),
1313 );
1314 let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1315 assert!(names.contains(&"Accept"));
1316 assert!(names.contains(&"X-Forwarded-For"));
1317 assert!(names.contains(&"True-Client-IP"));
1318 assert!(names.contains(&"CF-Connecting-IP"));
1319 let geo_values: Vec<&str> =
1321 headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1322 for v in geo_values {
1323 assert_eq!(v, "203.0.113.42");
1324 }
1325 }
1326
1327 #[test]
1331 fn effective_op_headers_respects_spec_declared_header() {
1332 let ip: IpAddr = "203.0.113.99".parse().unwrap();
1333 let headers = effective_op_headers(
1334 &[("x-forwarded-for".into(), "10.0.0.1".into())],
1335 Some(ip),
1336 &["X-Forwarded-For".to_string()],
1337 );
1338 let xff: Vec<&str> = headers
1341 .iter()
1342 .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1343 .map(|(_, v)| v.as_str())
1344 .collect();
1345 assert_eq!(xff, vec!["10.0.0.1"]);
1346 }
1347
1348 #[test]
1350 fn effective_op_headers_is_a_noop_without_geo_ip() {
1351 let base = vec![("Accept".into(), "json".into())];
1352 let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1353 assert_eq!(h1, base);
1354 let ip: IpAddr = "10.0.0.1".parse().unwrap();
1355 let h2 = effective_op_headers(&base, Some(ip), &[]);
1356 assert_eq!(h2, base);
1357 }
1358
1359 #[test]
1364 fn build_client_pool_one_per_source_ip() {
1365 let mut cfg = SelfTestConfig {
1366 target_url: "http://127.0.0.1:1".into(),
1367 timeout: Duration::from_millis(200),
1368 ..Default::default()
1369 };
1370 assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
1372 cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
1374 assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
1375 }
1376
1377 #[tokio::test]
1384 async fn run_self_test_with_geo_source_completes() {
1385 let cfg = SelfTestConfig {
1386 target_url: "http://127.0.0.1:1".into(),
1387 timeout: Duration::from_millis(200),
1388 geo_source_ips: vec![
1389 "203.0.113.1".parse().unwrap(),
1390 "203.0.113.2".parse().unwrap(),
1391 ],
1392 ..Default::default()
1393 };
1394 let ops = vec![
1395 op("GET", "/a", None, vec![], vec![], vec![]),
1396 op("GET", "/b", None, vec![], vec![], vec![]),
1397 op("GET", "/c", None, vec![], vec![], vec![]),
1398 ];
1399 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1400 assert_eq!(report.operations.len(), 3);
1401 }
1402
1403 #[test]
1404 fn json_serialises_report() {
1405 let r = SelfTestReport {
1406 positive_pass: 1,
1407 positive_fail: 0,
1408 negative_caught: BTreeMap::new(),
1409 negative_missed: BTreeMap::new(),
1410 operations: vec![OperationResult {
1411 method: "GET".into(),
1412 path: "/x".into(),
1413 positive: Some(CaseOutcome {
1414 label: "positive".into(),
1415 expected_4xx: false,
1416 actual_status: 200,
1417 passed: true,
1418 }),
1419 negatives: Vec::new(),
1420 }],
1421 };
1422 let json = serde_json::to_value(&r).expect("serialises");
1423 assert_eq!(json["positive_pass"], serde_json::json!(1));
1424 assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
1425 }
1426}