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
44const CONTENT_TYPE_SWAP_VARIANTS: &[(&str, &str)] = &[
51 ("application/xml", "request-body:content-type-mismatch:xml"),
52 ("application/yaml", "request-body:content-type-mismatch:yaml"),
53 ("multipart/form-data", "request-body:content-type-mismatch:multipart"),
54 (
55 "application/x-www-form-urlencoded",
56 "request-body:content-type-mismatch:urlencoded",
57 ),
58];
59
60#[derive(Debug, Clone)]
62pub struct SelfTestConfig {
63 pub target_url: String,
64 pub skip_tls_verify: bool,
65 pub timeout: Duration,
66 pub extra_headers: Vec<(String, String)>,
68 pub delay_between_requests: Duration,
70 pub base_path: Option<String>,
77 pub source_ips: Vec<IpAddr>,
81 pub geo_source_ips: Vec<IpAddr>,
85 pub geo_source_headers: Vec<String>,
89 pub capture: Option<Arc<Mutex<Vec<CaseCapture>>>>,
95 pub validate_response_schemas: bool,
103}
104
105#[derive(Debug, Clone, serde::Serialize)]
112pub struct CaseCapture {
113 pub label: String,
114 pub method: String,
115 pub url: String,
116 pub request_headers: BTreeMap<String, String>,
117 pub request_body: Option<String>,
118 pub request_body_truncated: bool,
119 pub response_status: u16,
120 pub response_headers: BTreeMap<String, String>,
121 pub response_body: Option<String>,
122 pub response_body_truncated: bool,
123 pub error: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub response_schema_error: Option<String>,
131}
132
133impl Default for SelfTestConfig {
134 fn default() -> Self {
135 Self {
136 target_url: "http://localhost:3000".into(),
137 skip_tls_verify: false,
138 timeout: Duration::from_secs(15),
139 extra_headers: Vec::new(),
140 delay_between_requests: Duration::from_millis(0),
141 base_path: None,
142 source_ips: Vec::new(),
143 geo_source_ips: Vec::new(),
144 geo_source_headers: default_geo_source_headers(),
145 capture: None,
146 validate_response_schemas: false,
147 }
148 }
149}
150
151fn truncate_body_for_capture(body: &str) -> (String, bool) {
155 if body.len() <= CAPTURE_BODY_CAP_BYTES {
156 return (body.to_string(), false);
157 }
158 let mut end = CAPTURE_BODY_CAP_BYTES;
159 while end > 0 && !body.is_char_boundary(end) {
160 end -= 1;
161 }
162 (body[..end].to_string(), true)
163}
164
165pub fn default_geo_source_headers() -> Vec<String> {
172 vec![
173 "X-Forwarded-For".to_string(),
174 "True-Client-IP".to_string(),
175 "CF-Connecting-IP".to_string(),
176 ]
177}
178
179#[derive(Debug, Clone, serde::Serialize)]
181pub struct CaseOutcome {
182 pub label: String,
183 pub expected_4xx: bool,
184 pub actual_status: u16,
185 pub passed: bool,
188}
189
190#[derive(Debug, Clone, serde::Serialize)]
192pub struct OperationResult {
193 pub method: String,
194 pub path: String,
195 pub positive: Option<CaseOutcome>,
196 pub negatives: Vec<CaseOutcome>,
197}
198
199#[derive(Debug, Default, Clone, serde::Serialize)]
201pub struct SelfTestReport {
202 pub positive_pass: usize,
203 pub positive_fail: usize,
204 pub negative_caught: BTreeMap<String, usize>,
207 pub negative_missed: BTreeMap<String, usize>,
210 pub operations: Vec<OperationResult>,
211}
212
213impl SelfTestReport {
214 pub fn all_passed(&self) -> bool {
217 self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
218 }
219
220 pub fn detect_target_misconfiguration(&self) -> Option<u16> {
229 if self.positive_pass > 0 || self.positive_fail < 10 {
230 return None;
231 }
232 let mut seen: Option<u16> = None;
233 for op in &self.operations {
234 let Some(p) = &op.positive else {
235 continue;
236 };
237 if p.passed {
238 return None;
239 }
240 match seen {
241 None => seen = Some(p.actual_status),
242 Some(s) if s != p.actual_status => return None,
243 _ => {}
244 }
245 }
246 seen
247 }
248
249 pub fn render_summary(&self) -> String {
253 let mut out = String::new();
254 out.push_str(&format!(
255 "Positives: {} pass / {} fail\n",
256 self.positive_pass, self.positive_fail
257 ));
258 let mut keys: Vec<&String> =
259 self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
260 keys.sort();
261 keys.dedup();
262 for cat in keys {
263 let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
264 let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
265 let mark = if missed == 0 { "✓" } else { "⚠" };
266 out.push_str(&format!(
267 "Negatives [{}]: {} caught / {} missed {}\n",
268 cat, caught, missed, mark
269 ));
270 }
271 out
272 }
273}
274
275pub async fn run_self_test(
280 operations: &[AnnotatedOperation],
281 config: &SelfTestConfig,
282) -> Result<SelfTestReport, reqwest::Error> {
283 let clients = build_client_pool(config)?;
288 let client_cursor = AtomicUsize::new(0);
289 let geo_cursor = AtomicUsize::new(0);
290
291 let mut report = SelfTestReport::default();
292 for op in operations {
293 let client_idx = client_cursor.fetch_add(1, Ordering::Relaxed) % clients.len();
294 let client = &clients[client_idx];
295 let geo_ip = if config.geo_source_ips.is_empty() {
296 None
297 } else {
298 let idx = geo_cursor.fetch_add(1, Ordering::Relaxed) % config.geo_source_ips.len();
299 Some(config.geo_source_ips[idx])
300 };
301 let result = test_operation(client, config, op, geo_ip).await;
302 if let Some(p) = &result.positive {
303 if p.passed {
304 report.positive_pass += 1;
305 } else {
306 report.positive_fail += 1;
307 }
308 }
309 for neg in &result.negatives {
310 let cat = neg.label.split(':').next().unwrap_or("other").to_string();
311 if neg.passed {
312 *report.negative_caught.entry(cat).or_insert(0) += 1;
313 } else {
314 *report.negative_missed.entry(cat).or_insert(0) += 1;
315 }
316 }
317 report.operations.push(result);
318 if !config.delay_between_requests.is_zero() {
319 tokio::time::sleep(config.delay_between_requests).await;
320 }
321 }
322 Ok(report)
323}
324
325fn effective_op_headers(
333 base: &[(String, String)],
334 geo_ip: Option<IpAddr>,
335 geo_headers: &[String],
336) -> Vec<(String, String)> {
337 let mut out = base.to_vec();
338 let Some(ip) = geo_ip else {
339 return out;
340 };
341 let value = ip.to_string();
342 for h in geo_headers {
343 if out.iter().any(|(k, _)| k.eq_ignore_ascii_case(h)) {
346 continue;
347 }
348 out.push((h.clone(), value.clone()));
349 }
350 out
351}
352
353fn build_client_pool(config: &SelfTestConfig) -> Result<Vec<Client>, reqwest::Error> {
361 let make = |bind: Option<IpAddr>| -> Result<Client, reqwest::Error> {
362 let mut builder = Client::builder().timeout(config.timeout);
363 if config.skip_tls_verify {
364 builder = builder.danger_accept_invalid_certs(true);
365 }
366 if let Some(addr) = bind {
367 builder = builder.local_address(addr);
368 }
369 builder.build()
370 };
371 if config.source_ips.is_empty() {
372 Ok(vec![make(None)?])
373 } else {
374 config.source_ips.iter().map(|ip| make(Some(*ip))).collect()
375 }
376}
377
378async fn test_operation(
379 client: &Client,
380 config: &SelfTestConfig,
381 op: &AnnotatedOperation,
382 geo_ip: Option<IpAddr>,
383) -> OperationResult {
384 let sink_start = config.capture.as_ref().and_then(|s| s.lock().ok().map(|g| g.len()));
390
391 let url = build_url_with_base(
392 &config.target_url,
393 config.base_path.as_deref(),
394 &op.path,
395 &op.path_params,
396 );
397 let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
398
399 let op_headers = effective_op_headers(&op.header_params, geo_ip, &config.geo_source_headers);
404
405 let positive = send_case(
407 client,
408 config,
409 method.clone(),
410 &url,
411 "positive",
412 false,
413 op.sample_body.as_deref(),
414 op.query_params.clone(),
415 op_headers.clone(),
416 )
417 .await;
418
419 let mut negatives = Vec::new();
421
422 if op.request_body_content_type.is_some() {
431 negatives.push(
432 send_case(
433 client,
434 config,
435 method.clone(),
436 &url,
437 "request-body:empty",
438 true,
439 Some("{}"),
440 op.query_params.clone(),
441 op_headers.clone(),
442 )
443 .await,
444 );
445
446 negatives.push(
450 send_case(
451 client,
452 config,
453 method.clone(),
454 &url,
455 "request-body:wrong-type",
456 true,
457 Some("[]"),
458 op.query_params.clone(),
459 op_headers.clone(),
460 )
461 .await,
462 );
463
464 if op
483 .request_body_content_type
484 .as_deref()
485 .map(|ct| ct.contains("json"))
486 .unwrap_or(false)
487 {
488 let payload = op.sample_body.as_deref().unwrap_or("{}");
489 for (ct, label) in CONTENT_TYPE_SWAP_VARIANTS {
490 negatives.push(
491 send_case_with_extra(
492 client,
493 config,
494 method.clone(),
495 &url,
496 label,
497 true,
498 Some(payload),
499 op.query_params.clone(),
500 op_headers
504 .iter()
505 .filter(|(k, _)| !k.eq_ignore_ascii_case("content-type"))
506 .cloned()
507 .collect(),
508 vec![("Content-Type".to_string(), (*ct).to_string())],
517 )
518 .await,
519 );
520 }
521 }
522
523 if let (Some(sample_str), Some(schema)) =
532 (op.sample_body.as_deref(), op.request_body_schema.as_ref())
533 {
534 if let Ok(sample) = serde_json::from_str::<serde_json::Value>(sample_str) {
535 let mutations = super::schema_mutator::mutate_body(&sample, schema);
536 for m in mutations.into_iter().take(SCHEMA_MUTATION_CAP) {
537 let body_str = serde_json::to_string(&m.body).unwrap_or_default();
538 negatives.push(
539 send_case(
540 client,
541 config,
542 method.clone(),
543 &url,
544 &m.label,
545 true,
546 Some(&body_str),
547 op.query_params.clone(),
548 op_headers.clone(),
555 )
556 .await,
557 );
558 }
559 }
560 }
561 }
562
563 {
568 let pad = "p=".to_string() + &"x".repeat(9_000);
569 let bad_url = if url.contains('?') {
570 format!("{url}&{pad}")
571 } else {
572 format!("{url}?{pad}")
573 };
574 negatives.push(
575 send_case(
576 client,
577 config,
578 method.clone(),
579 &bad_url,
580 "parameters:uri-too-long",
581 true,
582 op.sample_body.as_deref(),
583 op.query_params.clone(),
584 op_headers.clone(),
588 )
589 .await,
590 );
591 }
592
593 if !op.path_params.is_empty() {
607 let mut url_with_placeholder = op.path.clone();
608 if let Some((first_name, _)) = op.path_params.first() {
609 for (name, value) in op.path_params.iter().skip(1) {
612 if !value.is_empty() {
613 url_with_placeholder =
614 url_with_placeholder.replace(&format!("{{{name}}}"), value);
615 }
616 }
617 url_with_placeholder =
621 url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
622 let bad_url = build_url_with_base(
626 &config.target_url,
627 config.base_path.as_deref(),
628 &url_with_placeholder,
629 &[],
630 );
631 negatives.push(
632 send_case(
633 client,
634 config,
635 method.clone(),
636 &bad_url,
637 "parameters:bad-path-param",
638 true,
639 op.sample_body.as_deref(),
640 op.query_params.clone(),
641 op_headers.clone(),
642 )
643 .await,
644 );
645 }
646 }
647
648 if !op.query_params.is_empty() {
650 let mut q = op.query_params.clone();
651 q.remove(0);
652 negatives.push(
653 send_case(
654 client,
655 config,
656 method.clone(),
657 &url,
658 "parameters:missing-query",
659 true,
660 op.sample_body.as_deref(),
661 q,
662 op_headers.clone(),
663 )
664 .await,
665 );
666 }
667
668 for probe in build_security_probes(&op.security_schemes) {
686 let stripped_extra = strip_auth(&config.extra_headers, &op.security_schemes);
690 let stripped_headers = strip_auth(&op.header_params, &op.security_schemes);
691 let stripped_query = strip_auth_query(&op.query_params, &op.security_schemes);
692 let mut req_headers = stripped_headers;
693 for (k, v) in &probe.headers {
694 req_headers.push((k.clone(), v.clone()));
695 }
696 if let Some(ip) = geo_ip {
702 let ip_str = ip.to_string();
703 for h in &config.geo_source_headers {
704 let already = req_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(h));
705 if !already {
706 req_headers.push((h.clone(), ip_str.clone()));
707 }
708 }
709 }
710 let mut req_query = stripped_query;
711 for (k, v) in &probe.query {
712 req_query.push((k.clone(), v.clone()));
713 }
714 negatives.push(
715 send_case_with_extra(
716 client,
717 config,
718 method.clone(),
719 &url,
720 &probe.label,
721 true,
722 op.sample_body.as_deref(),
723 req_query,
724 req_headers,
725 stripped_extra,
726 )
727 .await,
728 );
729 }
730
731 if !op.header_params.is_empty() {
733 let mut h = op_headers.clone();
739 if !h.is_empty() {
740 h.remove(0);
741 }
742 negatives.push(
743 send_case(
744 client,
745 config,
746 method.clone(),
747 &url,
748 "parameters:missing-header",
749 true,
750 op.sample_body.as_deref(),
751 op.query_params.clone(),
752 h,
753 )
754 .await,
755 );
756 }
757
758 for probe in build_owasp_probes(op) {
778 negatives.push(
779 send_case(
780 client,
781 config,
782 method.clone(),
783 &url,
784 &probe.label,
785 true,
786 probe.body.as_deref(),
787 probe.query,
788 op_headers.clone(),
793 )
794 .await,
795 );
796 }
797
798 if config.validate_response_schemas {
805 if let (Some(sink), Some(start)) = (config.capture.as_ref(), sink_start) {
806 if !op.response_schemas.is_empty() {
807 if let Ok(mut guard) = sink.lock() {
808 let end = guard.len();
809 for i in start..end {
810 let Some(entry) = guard.get_mut(i) else {
811 continue;
812 };
813 let Some(body) = entry.response_body.as_deref() else {
814 continue;
815 };
816 let Some(schema) = op.response_schemas.get(&entry.response_status) else {
817 continue;
818 };
819 entry.response_schema_error = validate_body_against_schema(body, schema);
820 }
821 }
822 }
823 }
824 }
825
826 OperationResult {
827 method: op.method.clone(),
828 path: op.path.clone(),
829 positive: Some(positive),
830 negatives,
831 }
832}
833
834fn validate_body_against_schema(body: &str, schema: &serde_json::Value) -> Option<String> {
842 let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
843 let validator = jsonschema::validator_for(schema).ok()?;
844 let mut errors = validator.iter_errors(&parsed);
845 let first = errors.next()?;
846 let path = first.instance_path.to_string();
856 let path = if path.is_empty() { "/" } else { path.as_str() };
857 let kind_msg: String = match &first.kind {
858 jsonschema::error::ValidationErrorKind::Type { kind } => {
859 match kind {
863 jsonschema::error::TypeKind::Single(t) => format!("expected type {t}"),
864 jsonschema::error::TypeKind::Multiple(_) => "expected one of multiple types".into(),
865 }
866 }
867 jsonschema::error::ValidationErrorKind::Required { property } => {
868 format!("required field missing: {property}")
869 }
870 jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } => {
871 format!("unexpected additional properties: {unexpected:?}")
872 }
873 jsonschema::error::ValidationErrorKind::Enum { options } => {
874 format!("value not in allowed enum: {options}")
875 }
876 jsonschema::error::ValidationErrorKind::MinLength { limit } => {
877 format!("string shorter than min length ({limit})")
878 }
879 jsonschema::error::ValidationErrorKind::MaxLength { limit } => {
880 format!("string longer than max length ({limit})")
881 }
882 jsonschema::error::ValidationErrorKind::Minimum { limit } => {
883 format!("value below minimum ({limit})")
884 }
885 jsonschema::error::ValidationErrorKind::Maximum { limit } => {
886 format!("value above maximum ({limit})")
887 }
888 jsonschema::error::ValidationErrorKind::Pattern { pattern } => {
889 format!("value did not match pattern {pattern}")
890 }
891 _ => first.to_string().trim().to_string(),
895 };
896 Some(format!("at {path}: {kind_msg}"))
897}
898
899#[derive(Debug, Clone)]
901struct OwaspProbe {
902 label: String,
903 body: Option<String>,
904 query: Vec<(String, String)>,
905}
906
907fn build_owasp_probes(op: &AnnotatedOperation) -> Vec<OwaspProbe> {
911 use crate::security_payloads::{SecurityCategory, SecurityPayloads};
912
913 let categories = [
914 SecurityCategory::SqlInjection,
915 SecurityCategory::Xss,
916 SecurityCategory::CommandInjection,
917 SecurityCategory::PathTraversal,
918 SecurityCategory::Ssti,
919 SecurityCategory::LdapInjection,
920 SecurityCategory::Xxe,
921 ];
922
923 let injection_target = pick_injection_target(op);
927 let Some(target) = injection_target else {
928 return Vec::new();
929 };
930
931 let mut probes = Vec::new();
932 for cat in categories {
933 let Some(payload) = SecurityPayloads::get_by_category(cat).into_iter().next() else {
938 continue;
939 };
940 let mut query = op.query_params.clone();
941 let mut body = op.sample_body.clone();
942 match &target {
943 InjectionTarget::Query(idx) => {
944 if let Some(slot) = query.get_mut(*idx) {
945 slot.1 = payload.payload.clone();
946 }
947 }
948 InjectionTarget::BodyStringField(field) => {
949 body = inject_into_body_field(body.as_deref(), field, &payload.payload);
950 }
951 }
952 probes.push(OwaspProbe {
953 label: format!("owasp:{}", cat),
954 body,
955 query,
956 });
957 }
958 probes
959}
960
961#[derive(Debug, Clone)]
962enum InjectionTarget {
963 Query(usize),
964 BodyStringField(String),
965}
966
967fn pick_injection_target(op: &AnnotatedOperation) -> Option<InjectionTarget> {
968 if !op.query_params.is_empty() {
969 return Some(InjectionTarget::Query(0));
970 }
971 let sample = op.sample_body.as_deref()?;
972 let parsed: serde_json::Value = serde_json::from_str(sample).ok()?;
973 let obj = parsed.as_object()?;
974 for (k, v) in obj {
975 if v.is_string() {
976 return Some(InjectionTarget::BodyStringField(k.clone()));
977 }
978 }
979 None
980}
981
982fn inject_into_body_field(body: Option<&str>, field: &str, payload: &str) -> Option<String> {
986 let raw = body?;
987 let mut parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
988 let obj = parsed.as_object_mut()?;
989 obj.insert(field.to_string(), serde_json::json!(payload));
990 serde_json::to_string(&parsed).ok()
991}
992
993#[allow(clippy::too_many_arguments)]
994#[derive(Debug, Clone)]
996struct SecurityProbe {
997 label: String,
999 headers: Vec<(String, String)>,
1001 query: Vec<(String, String)>,
1003}
1004
1005fn build_security_probes(schemes: &[SecuritySchemeInfo]) -> Vec<SecurityProbe> {
1011 if schemes.is_empty() {
1012 return Vec::new();
1013 }
1014 let mut probes: Vec<SecurityProbe> = Vec::new();
1015 let mut seen_bearer = false;
1016 let mut seen_basic = false;
1017 let mut seen_apikey: std::collections::BTreeSet<(&'static str, String)> = Default::default();
1020 for s in schemes {
1021 match s {
1022 SecuritySchemeInfo::Bearer if !seen_bearer => {
1023 seen_bearer = true;
1024 probes.push(SecurityProbe {
1025 label: "security:bad-bearer".into(),
1026 headers: vec![(
1027 "Authorization".into(),
1028 "Bearer self-test-invalid-token".into(),
1029 )],
1030 query: Vec::new(),
1031 });
1032 }
1033 SecuritySchemeInfo::Basic if !seen_basic => {
1034 seen_basic = true;
1035 probes.push(SecurityProbe {
1037 label: "security:bad-basic".into(),
1038 headers: vec![(
1039 "Authorization".into(),
1040 "Basic c2VsZi10ZXN0OmludmFsaWQ=".into(),
1041 )],
1042 query: Vec::new(),
1043 });
1044 }
1045 SecuritySchemeInfo::ApiKey { location, name } => {
1046 let loc_tag = match location {
1047 ApiKeyLocation::Header => "header",
1048 ApiKeyLocation::Query => "query",
1049 ApiKeyLocation::Cookie => "cookie",
1050 };
1051 if seen_apikey.contains(&(loc_tag, name.clone())) {
1052 continue;
1053 }
1054 seen_apikey.insert((loc_tag, name.clone()));
1055 let label = format!("security:bad-apikey:{}", name);
1056 let bad = "self-test-invalid-key".to_string();
1057 match location {
1058 ApiKeyLocation::Header => probes.push(SecurityProbe {
1059 label,
1060 headers: vec![(name.clone(), bad)],
1061 query: Vec::new(),
1062 }),
1063 ApiKeyLocation::Query => probes.push(SecurityProbe {
1064 label,
1065 headers: Vec::new(),
1066 query: vec![(name.clone(), bad)],
1067 }),
1068 ApiKeyLocation::Cookie => probes.push(SecurityProbe {
1069 label,
1070 headers: vec![("Cookie".into(), format!("{}={}", name, bad))],
1071 query: Vec::new(),
1072 }),
1073 }
1074 }
1075 _ => {}
1076 }
1077 }
1078 probes.push(SecurityProbe {
1083 label: "security:no-auth".into(),
1084 headers: Vec::new(),
1085 query: Vec::new(),
1086 });
1087 probes
1088}
1089
1090fn strip_auth(
1094 headers: &[(String, String)],
1095 schemes: &[SecuritySchemeInfo],
1096) -> Vec<(String, String)> {
1097 let mut apikey_headers: std::collections::BTreeSet<String> = Default::default();
1098 for s in schemes {
1099 if let SecuritySchemeInfo::ApiKey {
1100 location: ApiKeyLocation::Header,
1101 name,
1102 } = s
1103 {
1104 apikey_headers.insert(name.to_lowercase());
1105 }
1106 if let SecuritySchemeInfo::ApiKey {
1107 location: ApiKeyLocation::Cookie,
1108 ..
1109 } = s
1110 {
1111 apikey_headers.insert("cookie".into());
1112 }
1113 }
1114 headers
1115 .iter()
1116 .filter(|(k, _)| {
1117 let lk = k.to_lowercase();
1118 lk != "authorization" && !apikey_headers.contains(&lk)
1119 })
1120 .cloned()
1121 .collect()
1122}
1123
1124fn strip_auth_query(
1127 query: &[(String, String)],
1128 schemes: &[SecuritySchemeInfo],
1129) -> Vec<(String, String)> {
1130 let mut apikey_query: std::collections::BTreeSet<String> = Default::default();
1131 for s in schemes {
1132 if let SecuritySchemeInfo::ApiKey {
1133 location: ApiKeyLocation::Query,
1134 name,
1135 } = s
1136 {
1137 apikey_query.insert(name.clone());
1138 }
1139 }
1140 query.iter().filter(|(k, _)| !apikey_query.contains(k)).cloned().collect()
1141}
1142
1143#[allow(clippy::too_many_arguments)]
1147async fn send_case_with_extra(
1148 client: &Client,
1149 config: &SelfTestConfig,
1150 method: Method,
1151 url: &str,
1152 label: &str,
1153 expected_4xx: bool,
1154 body: Option<&str>,
1155 query: Vec<(String, String)>,
1156 headers: Vec<(String, String)>,
1157 extra_headers: Vec<(String, String)>,
1158) -> CaseOutcome {
1159 let mut req = client.request(method.clone(), url);
1160 let mut capture_headers: BTreeMap<String, String> = BTreeMap::new();
1161 for (k, v) in &query {
1162 req = req.query(&[(k.as_str(), v.as_str())]);
1163 }
1164 if let Some(b) = body {
1170 req = req
1171 .header(reqwest::header::CONTENT_TYPE, "application/json")
1172 .body(b.to_string());
1173 capture_headers.insert("Content-Type".to_string(), "application/json".to_string());
1174 }
1175 for (k, v) in &headers {
1176 req = req.header(k, v);
1177 capture_headers.insert(k.clone(), v.clone());
1178 }
1179 for (k, v) in &extra_headers {
1180 req = req.header(k, v);
1181 capture_headers.insert(k.clone(), v.clone());
1182 }
1183 let (actual_status, response_capture) = match req.send().await {
1184 Ok(resp) => {
1185 let status = resp.status().as_u16();
1186 if let Some(sink) = &config.capture {
1187 let resp_headers: BTreeMap<String, String> = resp
1188 .headers()
1189 .iter()
1190 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
1191 .collect();
1192 let text = resp.text().await.unwrap_or_default();
1193 let (rb, truncated) = truncate_body_for_capture(&text);
1194 (status, Some((Some((rb, truncated)), resp_headers, None, sink.clone())))
1195 } else {
1196 (status, None)
1197 }
1198 }
1199 Err(e) => {
1200 let err_str = e.to_string();
1201 if let Some(sink) = &config.capture {
1202 (0, Some((None, BTreeMap::new(), Some(err_str), sink.clone())))
1203 } else {
1204 (0, None)
1205 }
1206 }
1207 };
1208 let passed = if expected_4xx {
1209 (400..500).contains(&actual_status)
1210 } else {
1211 (200..400).contains(&actual_status)
1212 };
1213 if let Some((resp_body, resp_headers, error, sink)) = response_capture {
1214 let (request_body, request_body_truncated) = match body {
1215 Some(b) => {
1216 let (rb, t) = truncate_body_for_capture(b);
1217 (Some(rb), t)
1218 }
1219 None => (None, false),
1220 };
1221 let (response_body, response_body_truncated) = match resp_body {
1222 Some((rb, t)) => (Some(rb), t),
1223 None => (None, false),
1224 };
1225 let entry = CaseCapture {
1226 label: label.to_string(),
1227 method: method.to_string(),
1228 url: build_query_url(url, &query),
1229 request_headers: capture_headers,
1230 request_body,
1231 request_body_truncated,
1232 response_status: actual_status,
1233 response_headers: resp_headers,
1234 response_body,
1235 response_body_truncated,
1236 error,
1237 response_schema_error: None,
1241 };
1242 if let Ok(mut guard) = sink.lock() {
1243 guard.push(entry);
1244 }
1245 }
1246 CaseOutcome {
1247 label: label.to_string(),
1248 expected_4xx,
1249 actual_status,
1250 passed,
1251 }
1252}
1253
1254#[allow(clippy::too_many_arguments)]
1260async fn send_case(
1261 client: &Client,
1262 config: &SelfTestConfig,
1263 method: Method,
1264 url: &str,
1265 label: &str,
1266 expected_4xx: bool,
1267 body: Option<&str>,
1268 query: Vec<(String, String)>,
1269 headers: Vec<(String, String)>,
1270) -> CaseOutcome {
1271 send_case_with_extra(
1275 client,
1276 config,
1277 method,
1278 url,
1279 label,
1280 expected_4xx,
1281 body,
1282 query,
1283 headers,
1284 config.extra_headers.clone(),
1285 )
1286 .await
1287}
1288
1289fn build_query_url(base: &str, query: &[(String, String)]) -> String {
1295 if query.is_empty() {
1296 return base.to_string();
1297 }
1298 let qs: String = query
1299 .iter()
1300 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
1301 .collect::<Vec<_>>()
1302 .join("&");
1303 if base.contains('?') {
1304 format!("{base}&{qs}")
1305 } else {
1306 format!("{base}?{qs}")
1307 }
1308}
1309
1310#[allow(dead_code)]
1320fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
1321 build_url_with_base(target, None, path_template, path_params)
1322}
1323
1324fn build_url_with_base(
1330 target: &str,
1331 base_path: Option<&str>,
1332 path_template: &str,
1333 path_params: &[(String, String)],
1334) -> String {
1335 let mut url = path_template.to_string();
1336 for (name, value) in path_params {
1337 let placeholder = format!("{{{}}}", name);
1338 if !value.is_empty() {
1339 url = url.replace(&placeholder, value);
1340 }
1341 }
1342 let target = target.trim_end_matches('/');
1343 let prefix = match base_path {
1344 Some(bp) if !bp.is_empty() => {
1345 let trimmed = bp.trim_end_matches('/');
1346 if trimmed.starts_with('/') {
1347 trimmed.to_string()
1348 } else {
1349 format!("/{}", trimmed)
1350 }
1351 }
1352 _ => String::new(),
1353 };
1354 let path = if url.starts_with('/') {
1355 url
1356 } else {
1357 format!("/{url}")
1358 };
1359 format!("{target}{prefix}{path}")
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 fn op(
1367 method: &str,
1368 path: &str,
1369 body: Option<&str>,
1370 query: Vec<(&str, &str)>,
1371 headers: Vec<(&str, &str)>,
1372 path_params: Vec<(&str, &str)>,
1373 ) -> AnnotatedOperation {
1374 AnnotatedOperation {
1375 method: method.into(),
1376 path: path.into(),
1377 features: Vec::new(),
1378 request_body_content_type: body.map(|_| "application/json".into()),
1379 sample_body: body.map(|s| s.to_string()),
1380 query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1381 header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1382 path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
1383 response_schema: None,
1384 response_schemas: std::collections::BTreeMap::new(),
1385 request_body_schema: None,
1386 security_schemes: Vec::new(),
1387 }
1388 }
1389
1390 #[test]
1391 fn build_url_substitutes_path_params() {
1392 let url = build_url(
1393 "https://api.test/",
1394 "/users/{id}/posts/{pid}",
1395 &[("id".into(), "42".into()), ("pid".into(), "7".into())],
1396 );
1397 assert_eq!(url, "https://api.test/users/42/posts/7");
1398 }
1399
1400 #[test]
1404 fn detect_target_misconfiguration_when_all_positives_share_status() {
1405 let mut report = SelfTestReport {
1406 positive_pass: 0,
1407 positive_fail: 50,
1408 ..Default::default()
1409 };
1410 for i in 0..50 {
1411 report.operations.push(OperationResult {
1412 method: "GET".into(),
1413 path: format!("/r/{i}"),
1414 positive: Some(CaseOutcome {
1415 label: "positive".into(),
1416 expected_4xx: false,
1417 actual_status: 404,
1418 passed: false,
1419 }),
1420 negatives: Vec::new(),
1421 });
1422 }
1423 assert_eq!(report.detect_target_misconfiguration(), Some(404));
1424 }
1425
1426 #[test]
1427 fn detect_target_misconfiguration_returns_none_when_some_pass() {
1428 let mut report = SelfTestReport {
1429 positive_pass: 5,
1430 positive_fail: 50,
1431 ..Default::default()
1432 };
1433 for i in 0..55 {
1434 report.operations.push(OperationResult {
1435 method: "GET".into(),
1436 path: format!("/r/{i}"),
1437 positive: Some(CaseOutcome {
1438 label: "positive".into(),
1439 expected_4xx: false,
1440 actual_status: if i < 5 { 200 } else { 404 },
1441 passed: i < 5,
1442 }),
1443 negatives: Vec::new(),
1444 });
1445 }
1446 assert_eq!(report.detect_target_misconfiguration(), None);
1447 }
1448
1449 #[test]
1454 fn build_url_applies_base_path_when_present() {
1455 let url = build_url_with_base(
1456 "https://api.example.com",
1457 Some("/api"),
1458 "/users/{id}",
1459 &[("id".into(), "42".into())],
1460 );
1461 assert_eq!(url, "https://api.example.com/api/users/42");
1462 }
1463
1464 #[test]
1468 fn build_url_normalises_base_path() {
1469 let no_slash = build_url_with_base("https://t", Some("api"), "/x", &[]);
1470 assert_eq!(no_slash, "https://t/api/x");
1471 let trailing = build_url_with_base("https://t", Some("/api/"), "/x", &[]);
1472 assert_eq!(trailing, "https://t/api/x");
1473 let empty = build_url_with_base("https://t", Some(""), "/x", &[]);
1474 assert_eq!(empty, "https://t/x");
1475 let none = build_url_with_base("https://t", None, "/x", &[]);
1476 assert_eq!(none, "https://t/x");
1477 }
1478
1479 #[test]
1480 fn build_url_keeps_placeholders_when_no_sample() {
1481 let url = build_url("https://api.test", "/users/{id}", &[]);
1482 assert_eq!(url, "https://api.test/users/{id}");
1483 }
1484
1485 #[test]
1486 fn report_summary_calls_out_misses() {
1487 let r = SelfTestReport {
1488 positive_pass: 3,
1489 positive_fail: 0,
1490 negative_caught: BTreeMap::from([("request-body".into(), 2)]),
1491 negative_missed: BTreeMap::from([("request-body".into(), 1)]),
1492 operations: Vec::new(),
1493 };
1494 let summary = r.render_summary();
1495 assert!(summary.contains("Positives: 3 pass / 0 fail"));
1496 assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
1497 assert!(summary.contains("⚠"));
1498 assert!(!r.all_passed());
1499 }
1500
1501 #[test]
1502 fn report_all_passed_when_no_miss() {
1503 let r = SelfTestReport {
1504 positive_pass: 5,
1505 positive_fail: 0,
1506 negative_caught: BTreeMap::from([("parameters".into(), 3)]),
1507 negative_missed: BTreeMap::new(),
1508 operations: Vec::new(),
1509 };
1510 assert!(r.all_passed());
1511 assert!(r.render_summary().contains("✓"));
1512 }
1513
1514 #[tokio::test]
1515 async fn run_self_test_against_unreachable_target_marks_all_failed() {
1516 let cfg = SelfTestConfig {
1519 target_url: "http://127.0.0.1:1".into(),
1520 timeout: Duration::from_millis(200),
1521 ..Default::default()
1522 };
1523 let ops = vec![op(
1524 "POST",
1525 "/users",
1526 Some("{\"name\":\"a\"}"),
1527 vec![],
1528 vec![],
1529 vec![],
1530 )];
1531 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1532 assert_eq!(report.positive_fail, 1);
1536 assert!(report.negative_missed.values().sum::<usize>() >= 1);
1537 assert!(!report.all_passed());
1538 }
1539
1540 #[tokio::test]
1546 async fn schema_driven_negatives_fire_when_schema_present() {
1547 use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
1548 let cfg = SelfTestConfig {
1549 target_url: "http://127.0.0.1:1".into(),
1550 timeout: Duration::from_millis(200),
1551 ..Default::default()
1552 };
1553 let mut obj = ObjectType::default();
1559 obj.properties.insert(
1560 "name".to_string(),
1561 ReferenceOr::Item(Box::new(Schema {
1562 schema_data: SchemaData::default(),
1563 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1564 })),
1565 );
1566 obj.properties.insert(
1567 "age".to_string(),
1568 ReferenceOr::Item(Box::new(Schema {
1569 schema_data: SchemaData::default(),
1570 schema_kind: SchemaKind::Type(Type::Integer(Default::default())),
1571 })),
1572 );
1573 obj.required = vec!["name".into(), "age".into()];
1574 let schema = Schema {
1575 schema_data: SchemaData::default(),
1576 schema_kind: SchemaKind::Type(Type::Object(obj)),
1577 };
1578
1579 let mut o =
1580 op("POST", "/users", Some(r#"{"name":"Ada","age":30}"#), vec![], vec![], vec![]);
1581 o.request_body_schema = Some(schema);
1582 let report = run_self_test(&[o], &cfg).await.expect("client builds");
1583 let labels: std::collections::BTreeSet<String> = report
1585 .operations
1586 .iter()
1587 .flat_map(|op| op.negatives.iter().map(|n| n.label.clone()))
1588 .collect();
1589 assert!(
1590 labels.iter().any(|l| l.starts_with("request-body:type-mismatch:")),
1591 "missing type-mismatch negative; got {labels:?}"
1592 );
1593 assert!(
1594 labels.iter().any(|l| l.starts_with("request-body:required-removed:")),
1595 "missing required-removed negative; got {labels:?}"
1596 );
1597 assert!(
1598 labels.iter().any(|l| l == "parameters:uri-too-long"),
1599 "missing URI-length negative; got {labels:?}"
1600 );
1601 }
1602
1603 #[tokio::test]
1608 async fn no_sample_body_still_produces_request_body_negatives() {
1609 let cfg = SelfTestConfig {
1610 target_url: "http://127.0.0.1:1".into(),
1611 timeout: Duration::from_millis(200),
1612 ..Default::default()
1613 };
1614 let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
1616 let mut ops_fixed = ops;
1618 ops_fixed[0].request_body_content_type = Some("application/json".into());
1619 let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
1620 assert!(
1624 report.negative_missed.values().sum::<usize>() >= 2,
1625 "expected ≥2 request-body negatives, got {:?}",
1626 report.negative_missed
1627 );
1628 }
1629
1630 #[tokio::test]
1635 async fn path_param_only_endpoint_produces_a_probe() {
1636 let cfg = SelfTestConfig {
1637 target_url: "http://127.0.0.1:1".into(),
1638 timeout: Duration::from_millis(200),
1639 ..Default::default()
1640 };
1641 let ops = vec![op(
1642 "GET",
1643 "/teams/{team-id}",
1644 None,
1645 vec![],
1646 vec![],
1647 vec![("team-id", "1")],
1648 )];
1649 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1650 let total: usize = report.negative_caught.values().sum::<usize>()
1651 + report.negative_missed.values().sum::<usize>();
1652 assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
1653 }
1654
1655 #[test]
1659 fn effective_op_headers_appends_geo_ip_to_default_headers() {
1660 let ip: IpAddr = "203.0.113.42".parse().unwrap();
1661 let headers = effective_op_headers(
1662 &[("Accept".into(), "application/json".into())],
1663 Some(ip),
1664 &default_geo_source_headers(),
1665 );
1666 let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
1667 assert!(names.contains(&"Accept"));
1668 assert!(names.contains(&"X-Forwarded-For"));
1669 assert!(names.contains(&"True-Client-IP"));
1670 assert!(names.contains(&"CF-Connecting-IP"));
1671 let geo_values: Vec<&str> =
1673 headers.iter().filter(|(k, _)| k != "Accept").map(|(_, v)| v.as_str()).collect();
1674 for v in geo_values {
1675 assert_eq!(v, "203.0.113.42");
1676 }
1677 }
1678
1679 #[test]
1683 fn effective_op_headers_respects_spec_declared_header() {
1684 let ip: IpAddr = "203.0.113.99".parse().unwrap();
1685 let headers = effective_op_headers(
1686 &[("x-forwarded-for".into(), "10.0.0.1".into())],
1687 Some(ip),
1688 &["X-Forwarded-For".to_string()],
1689 );
1690 let xff: Vec<&str> = headers
1693 .iter()
1694 .filter(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-for"))
1695 .map(|(_, v)| v.as_str())
1696 .collect();
1697 assert_eq!(xff, vec!["10.0.0.1"]);
1698 }
1699
1700 #[test]
1702 fn effective_op_headers_is_a_noop_without_geo_ip() {
1703 let base = vec![("Accept".into(), "json".into())];
1704 let h1 = effective_op_headers(&base, None, &default_geo_source_headers());
1705 assert_eq!(h1, base);
1706 let ip: IpAddr = "10.0.0.1".parse().unwrap();
1707 let h2 = effective_op_headers(&base, Some(ip), &[]);
1708 assert_eq!(h2, base);
1709 }
1710
1711 #[test]
1716 fn build_client_pool_one_per_source_ip() {
1717 let mut cfg = SelfTestConfig {
1718 target_url: "http://127.0.0.1:1".into(),
1719 timeout: Duration::from_millis(200),
1720 ..Default::default()
1721 };
1722 assert_eq!(build_client_pool(&cfg).expect("default builds").len(), 1);
1724 cfg.source_ips = vec!["127.0.0.1".parse().unwrap()];
1726 assert_eq!(build_client_pool(&cfg).expect("bind loopback").len(), 1);
1727 }
1728
1729 #[tokio::test]
1736 async fn run_self_test_with_geo_source_completes() {
1737 let cfg = SelfTestConfig {
1738 target_url: "http://127.0.0.1:1".into(),
1739 timeout: Duration::from_millis(200),
1740 geo_source_ips: vec![
1741 "203.0.113.1".parse().unwrap(),
1742 "203.0.113.2".parse().unwrap(),
1743 ],
1744 ..Default::default()
1745 };
1746 let ops = vec![
1747 op("GET", "/a", None, vec![], vec![], vec![]),
1748 op("GET", "/b", None, vec![], vec![], vec![]),
1749 op("GET", "/c", None, vec![], vec![], vec![]),
1750 ];
1751 let report = run_self_test(&ops, &cfg).await.expect("client builds");
1752 assert_eq!(report.operations.len(), 3);
1753 }
1754
1755 #[tokio::test]
1764 async fn geo_headers_present_on_every_probe_with_capture() {
1765 let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
1766 let cfg = SelfTestConfig {
1767 target_url: "http://127.0.0.1:1".into(),
1768 timeout: Duration::from_millis(50),
1769 geo_source_ips: vec!["203.0.113.5".parse().unwrap()],
1770 capture: Some(sink.clone()),
1771 ..Default::default()
1772 };
1773 let ops = vec![op(
1779 "GET",
1780 "/items",
1781 Some("{}"),
1782 vec![("id", "1")],
1783 vec![("X-Trace", "x")],
1784 vec![],
1785 )];
1786 let _ = run_self_test(&ops, &cfg).await.expect("client builds");
1787 let captures = sink.lock().unwrap();
1788 assert!(!captures.is_empty(), "self-test should record probes");
1789 let geo_headers: std::collections::HashSet<&str> =
1792 ["X-Forwarded-For", "True-Client-IP", "CF-Connecting-IP"].into_iter().collect();
1793 for c in captures.iter() {
1794 let has_geo = c
1795 .request_headers
1796 .iter()
1797 .any(|(k, v)| geo_headers.contains(k.as_str()) && v == "203.0.113.5");
1798 assert!(
1799 has_geo,
1800 "probe `{}` is missing the geo IP header; got headers: {:?}",
1801 c.label, c.request_headers
1802 );
1803 }
1804 }
1805
1806 #[tokio::test]
1813 async fn content_type_swap_probes_fire_for_json_bodies() {
1814 let sink: Arc<Mutex<Vec<CaseCapture>>> = Arc::new(Mutex::new(Vec::new()));
1815 let cfg = SelfTestConfig {
1816 target_url: "http://127.0.0.1:1".into(),
1817 timeout: Duration::from_millis(50),
1818 capture: Some(sink.clone()),
1819 ..Default::default()
1820 };
1821 let ops = vec![
1822 op("POST", "/users", Some("{\"name\":\"a\"}"), vec![], vec![], vec![]),
1823 op("GET", "/ping", None, vec![], vec![], vec![]),
1824 ];
1825 let _ = run_self_test(&ops, &cfg).await.expect("client builds");
1826 let captures = sink.lock().unwrap();
1827
1828 let swap_labels: Vec<&str> = captures
1829 .iter()
1830 .filter(|c| c.label.starts_with("request-body:content-type-mismatch:"))
1831 .map(|c| c.label.as_str())
1832 .collect();
1833 assert_eq!(
1834 swap_labels.len(),
1835 4,
1836 "expected 4 content-type-swap probes (one per variant), got: {swap_labels:?}"
1837 );
1838 let expected_labels = [
1839 "request-body:content-type-mismatch:xml",
1840 "request-body:content-type-mismatch:yaml",
1841 "request-body:content-type-mismatch:multipart",
1842 "request-body:content-type-mismatch:urlencoded",
1843 ];
1844 for want in expected_labels {
1845 assert!(swap_labels.contains(&want), "missing swap probe `{want}`");
1846 }
1847
1848 for c in captures.iter() {
1851 let Some(suffix) = c.label.strip_prefix("request-body:content-type-mismatch:") else {
1852 continue;
1853 };
1854 let want_ct = match suffix {
1855 "xml" => "application/xml",
1856 "yaml" => "application/yaml",
1857 "multipart" => "multipart/form-data",
1858 "urlencoded" => "application/x-www-form-urlencoded",
1859 _ => continue,
1860 };
1861 let got_ct = c
1862 .request_headers
1863 .iter()
1864 .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1865 .map(|(_, v)| v.as_str())
1866 .unwrap_or("");
1867 assert_eq!(got_ct, want_ct, "swap probe `{}` sent wrong CT", c.label);
1868 }
1869
1870 let body_less_swaps = captures
1873 .iter()
1874 .filter(|c| {
1875 c.label.starts_with("request-body:content-type-mismatch:")
1876 && c.url.ends_with("/ping")
1877 })
1878 .count();
1879 assert_eq!(
1880 body_less_swaps, 0,
1881 "GET /ping has no request body; should not produce content-type-swap probes"
1882 );
1883 }
1884
1885 #[test]
1892 fn response_schema_error_message_is_readable() {
1893 let schema = serde_json::json!({"type": "string"});
1894 let body = r#"{"data":{},"id":"generated_id","status":"created"}"#;
1895 let err = validate_body_against_schema(body, &schema).expect("type-mismatch fires");
1896 assert!(!err.contains("Type { kind"), "stale debug output: {err}");
1900 assert!(!err.contains("{ kind:"), "stale debug output: {err}");
1901 assert!(err.contains("string"), "should name expected type: {err}");
1902 assert!(err.contains("at /"), "should include instance path: {err}");
1903 }
1904
1905 #[test]
1906 fn response_schema_error_required_field_is_readable() {
1907 let schema = serde_json::json!({
1908 "type": "object",
1909 "required": ["id"],
1910 "properties": {"id": {"type": "integer"}}
1911 });
1912 let body = r#"{"other": 1}"#;
1913 let err = validate_body_against_schema(body, &schema).expect("required-missing fires");
1914 assert!(err.contains("required field missing"), "{err}");
1915 assert!(err.contains("id"), "{err}");
1916 }
1917
1918 #[test]
1919 fn response_schema_error_none_on_match() {
1920 let schema = serde_json::json!({"type": "string"});
1921 assert_eq!(validate_body_against_schema("\"hello\"", &schema), None);
1922 }
1923
1924 #[test]
1925 fn json_serialises_report() {
1926 let r = SelfTestReport {
1927 positive_pass: 1,
1928 positive_fail: 0,
1929 negative_caught: BTreeMap::new(),
1930 negative_missed: BTreeMap::new(),
1931 operations: vec![OperationResult {
1932 method: "GET".into(),
1933 path: "/x".into(),
1934 positive: Some(CaseOutcome {
1935 label: "positive".into(),
1936 expected_4xx: false,
1937 actual_status: 200,
1938 passed: true,
1939 }),
1940 negatives: Vec::new(),
1941 }],
1942 };
1943 let json = serde_json::to_value(&r).expect("serialises");
1944 assert_eq!(json["positive_pass"], serde_json::json!(1));
1945 assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
1946 }
1947}