1use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use tempfile::TempDir;
31
32use crate::command::BenchCommand;
33use crate::error::{BenchError, Result};
34use crate::executor::{K6Executor, K6Results};
35use crate::ssrf::{validate_target_url, Policy as SsrfPolicy};
36
37fn resolve_ssrf_policy() -> SsrfPolicy {
44 match std::env::var("MOCKFORGE_SSRF_ALLOW_LOOPBACK").as_deref() {
45 Ok("1") | Ok("true") => SsrfPolicy::for_test(),
46 _ => SsrfPolicy::strict(),
47 }
48}
49
50async fn enforce_ssrf(target_url: &str) -> Result<()> {
54 let policy = resolve_ssrf_policy();
55 validate_target_url(target_url, policy)
56 .await
57 .map_err(|e| BenchError::Other(format!("SSRF guard rejected target: {}", e)))
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub enum SpecFormat {
63 Json,
65 Yaml,
67 #[default]
69 Auto,
70}
71
72impl SpecFormat {
73 fn extension(self, bytes: &[u8]) -> &'static str {
74 match self {
75 SpecFormat::Json => "json",
76 SpecFormat::Yaml => "yaml",
77 SpecFormat::Auto => match bytes.iter().find(|b| !b.is_ascii_whitespace()) {
78 Some(b'{') | Some(b'[') => "json",
79 _ => "yaml",
80 },
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
91pub struct CloudBenchInputs {
92 pub spec_bytes: Vec<u8>,
93 pub spec_format: SpecFormat,
94 pub target_url: String,
95 pub base_path: Option<String>,
96 pub duration: String,
97 pub vus: u32,
98 pub scenario: String,
99 pub operations: Option<String>,
100 pub exclude_operations: Option<String>,
101 pub auth: Option<String>,
102 pub headers: Option<String>,
105 pub threshold_percentile: String,
106 pub threshold_ms: u64,
107 pub max_error_rate: f64,
108 pub skip_tls_verify: bool,
109 pub chunked_request_bodies: bool,
110}
111
112impl Default for CloudBenchInputs {
113 fn default() -> Self {
114 Self {
115 spec_bytes: Vec::new(),
116 spec_format: SpecFormat::Auto,
117 target_url: String::new(),
118 base_path: None,
119 duration: "30s".to_string(),
120 vus: 10,
121 scenario: "constant".to_string(),
122 operations: None,
123 exclude_operations: None,
124 auth: None,
125 headers: None,
126 threshold_percentile: "p(95)".to_string(),
127 threshold_ms: 1000,
128 max_error_rate: 0.01,
129 skip_tls_verify: false,
130 chunked_request_bodies: false,
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
142pub struct CloudConformanceInputs {
143 pub spec_bytes: Option<Vec<u8>>,
144 pub spec_format: SpecFormat,
145 pub target_url: String,
146 pub base_path: Option<String>,
147 pub api_key: Option<String>,
148 pub basic_auth: Option<String>,
150 pub categories: Option<String>,
152 pub headers: Vec<String>,
154 pub all_operations: bool,
155 pub request_delay_ms: u64,
156 pub use_k6: bool,
159 pub skip_tls_verify: bool,
160 pub report_format: String,
162 pub export_requests: bool,
163 pub validate_requests: bool,
164}
165
166impl Default for CloudConformanceInputs {
167 fn default() -> Self {
168 Self {
169 spec_bytes: None,
170 spec_format: SpecFormat::Auto,
171 target_url: String::new(),
172 base_path: None,
173 api_key: None,
174 basic_auth: None,
175 categories: None,
176 headers: Vec::new(),
177 all_operations: false,
178 request_delay_ms: 0,
179 use_k6: false,
180 skip_tls_verify: false,
181 report_format: "json".to_string(),
182 export_requests: false,
183 validate_requests: false,
184 }
185 }
186}
187
188#[derive(Debug, Default, Clone)]
194pub struct CloudRunArtifacts {
195 pub k6_results: Option<K6Results>,
196 pub files: HashMap<String, Vec<u8>>,
197}
198
199impl CloudRunArtifacts {
200 pub fn get(&self, name: &str) -> Option<&[u8]> {
201 self.files.get(name).map(Vec::as_slice)
202 }
203
204 pub fn get_string(&self, name: &str) -> Option<String> {
205 self.get(name).map(|b| String::from_utf8_lossy(b).into_owned())
206 }
207
208 pub fn get_json(&self, name: &str) -> Option<serde_json::Value> {
209 self.get(name).and_then(|b| serde_json::from_slice(b).ok())
210 }
211}
212
213pub async fn run_bench(inputs: CloudBenchInputs) -> Result<CloudRunArtifacts> {
219 if inputs.target_url.trim().is_empty() {
220 return Err(BenchError::Other("target_url is required".to_string()));
221 }
222 if inputs.spec_bytes.is_empty() {
223 return Err(BenchError::Other("spec_bytes is required for bench runs".to_string()));
224 }
225 if !K6Executor::is_k6_installed() {
226 return Err(BenchError::K6NotFound);
227 }
228 enforce_ssrf(&inputs.target_url).await?;
229
230 let workdir = TempDir::new()
231 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
232 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
233 let output_dir = workdir.path().join("output");
234 std::fs::create_dir_all(&output_dir)
235 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
236
237 let cmd = BenchCommand {
238 spec: vec![spec_path],
239 target: inputs.target_url,
240 base_path: inputs.base_path,
241 duration: inputs.duration,
242 vus: inputs.vus,
243 scenario: inputs.scenario,
244 operations: inputs.operations,
245 exclude_operations: inputs.exclude_operations,
246 auth: inputs.auth,
247 headers: inputs.headers,
248 threshold_percentile: inputs.threshold_percentile,
249 threshold_ms: inputs.threshold_ms,
250 max_error_rate: inputs.max_error_rate,
251 skip_tls_verify: inputs.skip_tls_verify,
252 chunked_request_bodies: inputs.chunked_request_bodies,
253 ..default_bench_command(&output_dir)
254 };
255
256 cmd.execute().await?;
257 read_artifacts(&output_dir)
258}
259
260pub async fn run_conformance(inputs: CloudConformanceInputs) -> Result<CloudRunArtifacts> {
266 if inputs.target_url.trim().is_empty() {
267 return Err(BenchError::Other("target_url is required".to_string()));
268 }
269 if inputs.use_k6 && !K6Executor::is_k6_installed() {
270 return Err(BenchError::K6NotFound);
271 }
272 enforce_ssrf(&inputs.target_url).await?;
273
274 let workdir = TempDir::new()
275 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
276 let output_dir = workdir.path().join("output");
277 std::fs::create_dir_all(&output_dir)
278 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
279
280 let spec_paths = if let Some(bytes) = &inputs.spec_bytes {
281 vec![write_spec(workdir.path(), bytes, inputs.spec_format)?]
282 } else {
283 Vec::new()
284 };
285
286 let report_path = output_dir.join("conformance-report.json");
287 let cmd = BenchCommand {
288 spec: spec_paths,
289 target: inputs.target_url,
290 base_path: inputs.base_path,
291 skip_tls_verify: inputs.skip_tls_verify,
292 conformance: true,
293 conformance_api_key: inputs.api_key,
294 conformance_basic_auth: inputs.basic_auth,
295 conformance_report: report_path,
296 conformance_categories: inputs.categories,
297 conformance_report_format: inputs.report_format,
298 conformance_headers: inputs.headers,
299 conformance_all_operations: inputs.all_operations,
300 conformance_delay_ms: inputs.request_delay_ms,
301 use_k6: inputs.use_k6,
302 export_requests: inputs.export_requests,
303 validate_requests: inputs.validate_requests,
304 ..default_bench_command(&output_dir)
305 };
306
307 cmd.execute().await?;
308 read_artifacts(&output_dir)
309}
310
311#[derive(Debug, Clone)]
317pub struct CloudOwaspInputs {
318 pub spec_bytes: Vec<u8>,
319 pub spec_format: SpecFormat,
320 pub target_url: String,
321 pub base_path: Option<String>,
322 pub categories: Option<String>,
325 pub auth_header: String,
327 pub auth_token: Option<String>,
330 pub admin_paths: Vec<String>,
334 pub id_fields: Option<String>,
337 pub report_format: String,
339 pub iterations: u32,
341 pub vus: u32,
342 pub skip_tls_verify: bool,
343 pub headers: Option<String>,
345}
346
347impl Default for CloudOwaspInputs {
348 fn default() -> Self {
349 Self {
350 spec_bytes: Vec::new(),
351 spec_format: SpecFormat::Auto,
352 target_url: String::new(),
353 base_path: None,
354 categories: None,
355 auth_header: "Authorization".to_string(),
356 auth_token: None,
357 admin_paths: Vec::new(),
358 id_fields: None,
359 report_format: "json".to_string(),
360 iterations: 1,
361 vus: 10,
362 skip_tls_verify: false,
363 headers: None,
364 }
365 }
366}
367
368#[derive(Debug, Clone)]
376pub struct CloudSecurityInputs {
377 pub spec_bytes: Vec<u8>,
378 pub spec_format: SpecFormat,
379 pub target_url: String,
380 pub base_path: Option<String>,
381 pub duration: String,
382 pub vus: u32,
383 pub scenario: String,
384 pub categories: Option<String>,
386 pub target_fields: Option<String>,
388 pub auth: Option<String>,
389 pub headers: Option<String>,
390 pub skip_tls_verify: bool,
391}
392
393impl Default for CloudSecurityInputs {
394 fn default() -> Self {
395 Self {
396 spec_bytes: Vec::new(),
397 spec_format: SpecFormat::Auto,
398 target_url: String::new(),
399 base_path: None,
400 duration: "30s".to_string(),
401 vus: 10,
402 scenario: "constant".to_string(),
403 categories: None,
404 target_fields: None,
405 auth: None,
406 headers: None,
407 skip_tls_verify: false,
408 }
409 }
410}
411
412#[derive(Debug, Clone)]
419pub struct CloudWafBenchInputs {
420 pub spec_bytes: Vec<u8>,
421 pub spec_format: SpecFormat,
422 pub target_url: String,
423 pub base_path: Option<String>,
424 pub duration: String,
425 pub vus: u32,
426 pub scenario: String,
427 pub rules_dir: String,
429 pub cycle_all: bool,
432 pub auth: Option<String>,
433 pub headers: Option<String>,
434 pub skip_tls_verify: bool,
435}
436
437impl Default for CloudWafBenchInputs {
438 fn default() -> Self {
439 Self {
440 spec_bytes: Vec::new(),
441 spec_format: SpecFormat::Auto,
442 target_url: String::new(),
443 base_path: None,
444 duration: "30s".to_string(),
445 vus: 10,
446 scenario: "constant".to_string(),
447 rules_dir: String::new(),
448 cycle_all: false,
449 auth: None,
450 headers: None,
451 skip_tls_verify: false,
452 }
453 }
454}
455
456#[derive(Debug, Clone)]
462pub struct CloudCrudFlowInputs {
463 pub spec_bytes: Vec<u8>,
464 pub spec_format: SpecFormat,
465 pub target_url: String,
466 pub base_path: Option<String>,
467 pub duration: String,
468 pub vus: u32,
469 pub scenario: String,
470 pub flow_config_yaml: Option<String>,
473 pub extract_fields: Option<String>,
476 pub auth: Option<String>,
477 pub headers: Option<String>,
478 pub skip_tls_verify: bool,
479}
480
481impl Default for CloudCrudFlowInputs {
482 fn default() -> Self {
483 Self {
484 spec_bytes: Vec::new(),
485 spec_format: SpecFormat::Auto,
486 target_url: String::new(),
487 base_path: None,
488 duration: "30s".to_string(),
489 vus: 10,
490 scenario: "constant".to_string(),
491 flow_config_yaml: None,
492 extract_fields: None,
493 auth: None,
494 headers: None,
495 skip_tls_verify: false,
496 }
497 }
498}
499
500pub async fn run_owasp(inputs: CloudOwaspInputs) -> Result<CloudRunArtifacts> {
504 if inputs.target_url.trim().is_empty() {
505 return Err(BenchError::Other("target_url is required".to_string()));
506 }
507 if inputs.spec_bytes.is_empty() {
508 return Err(BenchError::Other("spec_bytes is required for OWASP runs".to_string()));
509 }
510 if !K6Executor::is_k6_installed() {
511 return Err(BenchError::K6NotFound);
512 }
513 enforce_ssrf(&inputs.target_url).await?;
514
515 let workdir = TempDir::new()
516 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
517 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
518 let output_dir = workdir.path().join("output");
519 std::fs::create_dir_all(&output_dir)
520 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
521
522 let admin_paths_path = if !inputs.admin_paths.is_empty() {
523 let p = workdir.path().join("admin-paths.txt");
524 std::fs::write(&p, inputs.admin_paths.join("\n"))
525 .map_err(|e| BenchError::Other(format!("Failed to write admin paths file: {}", e)))?;
526 Some(p)
527 } else {
528 None
529 };
530
531 let report_path = output_dir.join("owasp-report.json");
532 let cmd = BenchCommand {
533 spec: vec![spec_path],
534 target: inputs.target_url,
535 base_path: inputs.base_path,
536 vus: inputs.vus,
537 skip_tls_verify: inputs.skip_tls_verify,
538 headers: inputs.headers,
539 owasp_api_top10: true,
540 owasp_categories: inputs.categories,
541 owasp_auth_header: inputs.auth_header,
542 owasp_auth_token: inputs.auth_token,
543 owasp_admin_paths: admin_paths_path,
544 owasp_id_fields: inputs.id_fields,
545 owasp_report: Some(report_path),
546 owasp_report_format: inputs.report_format,
547 owasp_iterations: inputs.iterations,
548 ..default_bench_command(&output_dir)
549 };
550
551 cmd.execute().await?;
552 read_artifacts(&output_dir)
553}
554
555pub async fn run_security(inputs: CloudSecurityInputs) -> Result<CloudRunArtifacts> {
559 if inputs.target_url.trim().is_empty() {
560 return Err(BenchError::Other("target_url is required".to_string()));
561 }
562 if inputs.spec_bytes.is_empty() {
563 return Err(BenchError::Other("spec_bytes is required for security runs".to_string()));
564 }
565 if !K6Executor::is_k6_installed() {
566 return Err(BenchError::K6NotFound);
567 }
568 enforce_ssrf(&inputs.target_url).await?;
569
570 let workdir = TempDir::new()
571 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
572 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
573 let output_dir = workdir.path().join("output");
574 std::fs::create_dir_all(&output_dir)
575 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
576
577 let cmd = BenchCommand {
578 spec: vec![spec_path],
579 target: inputs.target_url,
580 base_path: inputs.base_path,
581 duration: inputs.duration,
582 vus: inputs.vus,
583 scenario: inputs.scenario,
584 auth: inputs.auth,
585 headers: inputs.headers,
586 skip_tls_verify: inputs.skip_tls_verify,
587 security_test: true,
588 security_categories: inputs.categories,
589 security_target_fields: inputs.target_fields,
590 ..default_bench_command(&output_dir)
591 };
592
593 cmd.execute().await?;
594 read_artifacts(&output_dir)
595}
596
597pub async fn run_wafbench(inputs: CloudWafBenchInputs) -> Result<CloudRunArtifacts> {
602 if inputs.target_url.trim().is_empty() {
603 return Err(BenchError::Other("target_url is required".to_string()));
604 }
605 if inputs.spec_bytes.is_empty() {
606 return Err(BenchError::Other("spec_bytes is required for WAFBench runs".to_string()));
607 }
608 if inputs.rules_dir.trim().is_empty() {
609 return Err(BenchError::Other(
610 "rules_dir is required for WAFBench runs (point at the bundled CRS install)"
611 .to_string(),
612 ));
613 }
614 if !K6Executor::is_k6_installed() {
615 return Err(BenchError::K6NotFound);
616 }
617 enforce_ssrf(&inputs.target_url).await?;
618
619 let workdir = TempDir::new()
620 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
621 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
622 let output_dir = workdir.path().join("output");
623 std::fs::create_dir_all(&output_dir)
624 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
625
626 let cmd = BenchCommand {
627 spec: vec![spec_path],
628 target: inputs.target_url,
629 base_path: inputs.base_path,
630 duration: inputs.duration,
631 vus: inputs.vus,
632 scenario: inputs.scenario,
633 auth: inputs.auth,
634 headers: inputs.headers,
635 skip_tls_verify: inputs.skip_tls_verify,
636 wafbench_dir: Some(inputs.rules_dir),
637 wafbench_cycle_all: inputs.cycle_all,
638 ..default_bench_command(&output_dir)
639 };
640
641 cmd.execute().await?;
642 read_artifacts(&output_dir)
643}
644
645pub async fn run_crud_flow(inputs: CloudCrudFlowInputs) -> Result<CloudRunArtifacts> {
649 if inputs.target_url.trim().is_empty() {
650 return Err(BenchError::Other("target_url is required".to_string()));
651 }
652 if inputs.spec_bytes.is_empty() {
653 return Err(BenchError::Other("spec_bytes is required for CRUD flow runs".to_string()));
654 }
655 if !K6Executor::is_k6_installed() {
656 return Err(BenchError::K6NotFound);
657 }
658 enforce_ssrf(&inputs.target_url).await?;
659
660 let workdir = TempDir::new()
661 .map_err(|e| BenchError::Other(format!("Failed to create tempdir: {}", e)))?;
662 let spec_path = write_spec(workdir.path(), &inputs.spec_bytes, inputs.spec_format)?;
663 let output_dir = workdir.path().join("output");
664 std::fs::create_dir_all(&output_dir)
665 .map_err(|e| BenchError::Other(format!("Failed to create output dir: {}", e)))?;
666
667 let flow_config_path = if let Some(yaml) = &inputs.flow_config_yaml {
668 let p = workdir.path().join("flow-config.yaml");
669 std::fs::write(&p, yaml)
670 .map_err(|e| BenchError::Other(format!("Failed to write flow config: {}", e)))?;
671 Some(p)
672 } else {
673 None
674 };
675
676 let cmd = BenchCommand {
677 spec: vec![spec_path],
678 target: inputs.target_url,
679 base_path: inputs.base_path,
680 duration: inputs.duration,
681 vus: inputs.vus,
682 scenario: inputs.scenario,
683 auth: inputs.auth,
684 headers: inputs.headers,
685 skip_tls_verify: inputs.skip_tls_verify,
686 crud_flow: true,
687 flow_config: flow_config_path,
688 extract_fields: inputs.extract_fields,
689 ..default_bench_command(&output_dir)
690 };
691
692 cmd.execute().await?;
693 read_artifacts(&output_dir)
694}
695
696fn default_bench_command(output_dir: &Path) -> BenchCommand {
702 BenchCommand {
703 spec: Vec::new(),
704 spec_dir: None,
705 merge_conflicts: "error".to_string(),
706 spec_mode: "merge".to_string(),
707 dependency_config: None,
708 target: String::new(),
709 base_path: None,
710 duration: "30s".to_string(),
711 vus: 10,
712 scenario: "constant".to_string(),
713 operations: None,
714 exclude_operations: None,
715 auth: None,
716 headers: None,
717 output: output_dir.to_path_buf(),
718 generate_only: false,
719 script_output: None,
720 threshold_percentile: "p(95)".to_string(),
721 threshold_ms: 1000,
722 max_error_rate: 0.01,
723 verbose: false,
724 skip_tls_verify: false,
725 chunked_request_bodies: false,
726 targets_file: None,
727 max_concurrency: None,
728 results_format: "aggregated".to_string(),
729 params_file: None,
730 crud_flow: false,
731 flow_config: None,
732 extract_fields: None,
733 parallel_create: None,
734 data_file: None,
735 data_distribution: "unique-per-vu".to_string(),
736 data_mappings: None,
737 per_uri_control: false,
738 error_rate: None,
739 error_types: None,
740 security_test: false,
741 security_payloads: None,
742 security_categories: None,
743 security_target_fields: None,
744 wafbench_dir: None,
745 wafbench_cycle_all: false,
746 conformance: false,
747 conformance_api_key: None,
748 conformance_basic_auth: None,
749 conformance_report: output_dir.join("conformance-report.json"),
750 conformance_categories: None,
751 conformance_report_format: "json".to_string(),
752 conformance_headers: Vec::new(),
753 conformance_all_operations: false,
754 conformance_custom: None,
755 conformance_delay_ms: 0,
756 use_k6: false,
757 conformance_custom_filter: None,
758 export_requests: false,
759 validate_requests: false,
760 owasp_api_top10: false,
761 owasp_categories: None,
762 owasp_auth_header: "Authorization".to_string(),
763 owasp_auth_token: None,
764 owasp_admin_paths: None,
765 owasp_id_fields: None,
766 owasp_report: None,
767 owasp_report_format: "json".to_string(),
768 owasp_iterations: 1,
769 }
770}
771
772fn write_spec(dir: &Path, bytes: &[u8], format: SpecFormat) -> Result<PathBuf> {
773 let filename = format!("spec.{}", format.extension(bytes));
774 let path = dir.join(filename);
775 std::fs::write(&path, bytes)
776 .map_err(|e| BenchError::Other(format!("Failed to write spec to tempdir: {}", e)))?;
777 Ok(path)
778}
779
780fn read_artifacts(output_dir: &Path) -> Result<CloudRunArtifacts> {
781 let mut files = HashMap::new();
782 if output_dir.exists() {
783 let entries = std::fs::read_dir(output_dir)
784 .map_err(|e| BenchError::Other(format!("Failed to read output dir: {}", e)))?;
785 for entry in entries {
786 let entry =
787 entry.map_err(|e| BenchError::Other(format!("Failed to read entry: {}", e)))?;
788 let metadata = entry
789 .metadata()
790 .map_err(|e| BenchError::Other(format!("Failed to stat entry: {}", e)))?;
791 if !metadata.is_file() {
792 continue;
793 }
794 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
795 continue;
796 };
797 let bytes = std::fs::read(entry.path()).map_err(|e| {
798 BenchError::Other(format!("Failed to read artifact {}: {}", name, e))
799 })?;
800 files.insert(name, bytes);
801 }
802 }
803
804 let k6_results = files.get("summary.json").and_then(|bytes| parse_k6_summary(bytes).ok());
805
806 Ok(CloudRunArtifacts { k6_results, files })
807}
808
809fn parse_k6_summary(bytes: &[u8]) -> Result<K6Results> {
810 let json: serde_json::Value =
811 serde_json::from_slice(bytes).map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
812 let duration_values = &json["metrics"]["http_req_duration"]["values"];
813 Ok(K6Results {
814 total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
815 failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
818 .as_u64()
819 .unwrap_or(0),
820 avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
821 p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
822 p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
823 rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
824 vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
825 min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
826 max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
827 med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
828 p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
829 })
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835
836 #[test]
837 fn spec_format_extension_for_json_bytes() {
838 assert_eq!(SpecFormat::Auto.extension(b" {\"openapi\":\"3.0.0\"}"), "json");
839 assert_eq!(SpecFormat::Auto.extension(b"openapi: 3.0.0\n"), "yaml");
840 assert_eq!(SpecFormat::Json.extension(b"openapi: 3.0.0"), "json");
841 assert_eq!(SpecFormat::Yaml.extension(b"{}"), "yaml");
842 }
843
844 #[test]
845 fn write_spec_round_trips_bytes() {
846 let dir = TempDir::new().unwrap();
847 let path = write_spec(dir.path(), b"openapi: 3.0.0\n", SpecFormat::Yaml).unwrap();
848 assert!(path.ends_with("spec.yaml"));
849 let read_back = std::fs::read(&path).unwrap();
850 assert_eq!(read_back, b"openapi: 3.0.0\n");
851 }
852
853 #[test]
854 fn read_artifacts_collects_top_level_files_only() {
855 let dir = TempDir::new().unwrap();
856 let out = dir.path();
857 std::fs::write(out.join("summary.json"), br#"{"metrics":{}}"#).unwrap();
858 std::fs::write(out.join("k6-output.log"), b"hello").unwrap();
859 std::fs::create_dir(out.join("nested")).unwrap();
861 std::fs::write(out.join("nested").join("ignored.txt"), b"nope").unwrap();
862
863 let artifacts = read_artifacts(out).unwrap();
864 assert_eq!(artifacts.files.len(), 2);
865 assert!(artifacts.files.contains_key("summary.json"));
866 assert!(artifacts.files.contains_key("k6-output.log"));
867 assert!(!artifacts.files.contains_key("ignored.txt"));
868 }
869
870 #[test]
871 fn parse_k6_summary_handles_minimal_input() {
872 let bytes = br#"{"metrics":{}}"#;
873 let r = parse_k6_summary(bytes).unwrap();
874 assert_eq!(r.total_requests, 0);
875 assert_eq!(r.failed_requests, 0);
876 assert_eq!(r.error_rate(), 0.0);
877 }
878
879 #[test]
880 fn parse_k6_summary_extracts_values() {
881 let bytes = br#"{
882 "metrics": {
883 "http_reqs": {"values": {"count": 100, "rate": 33.5}},
884 "http_req_failed": {"values": {"passes": 4}},
885 "http_req_duration": {"values": {
886 "avg": 12.3, "med": 10.0, "min": 1.0, "max": 50.0,
887 "p(90)": 20.0, "p(95)": 25.0, "p(99)": 40.0
888 }},
889 "vus_max": {"values": {"value": 10}}
890 }
891 }"#;
892 let r = parse_k6_summary(bytes).unwrap();
893 assert_eq!(r.total_requests, 100);
894 assert_eq!(r.failed_requests, 4);
895 assert_eq!(r.rps, 33.5);
896 assert_eq!(r.p95_duration_ms, 25.0);
897 assert_eq!(r.vus_max, 10);
898 }
899
900 #[test]
901 fn cloud_run_artifacts_get_helpers() {
902 let mut a = CloudRunArtifacts::default();
903 a.files.insert("hello.txt".to_string(), b"world".to_vec());
904 a.files.insert("payload.json".to_string(), br#"{"x":1}"#.to_vec());
905
906 assert_eq!(a.get("hello.txt").unwrap(), b"world");
907 assert_eq!(a.get_string("hello.txt").unwrap(), "world");
908 assert_eq!(a.get_json("payload.json").unwrap()["x"], 1);
909 assert!(a.get("missing").is_none());
910 }
911
912 #[tokio::test]
913 async fn run_bench_rejects_empty_target() {
914 let inputs = CloudBenchInputs {
915 spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
916 ..Default::default()
917 };
918 let err = run_bench(inputs).await.unwrap_err();
919 assert!(matches!(err, BenchError::Other(_)));
920 }
921
922 #[tokio::test]
923 async fn run_bench_rejects_empty_spec() {
924 let inputs = CloudBenchInputs {
925 target_url: "https://example.com".to_string(),
926 ..Default::default()
927 };
928 let err = run_bench(inputs).await.unwrap_err();
929 assert!(matches!(err, BenchError::Other(_)));
930 }
931
932 #[tokio::test]
933 async fn run_conformance_rejects_empty_target() {
934 let inputs = CloudConformanceInputs::default();
935 let err = run_conformance(inputs).await.unwrap_err();
936 assert!(matches!(err, BenchError::Other(_)));
937 }
938
939 #[tokio::test]
940 async fn run_owasp_rejects_missing_inputs() {
941 let no_target = run_owasp(CloudOwaspInputs {
942 spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
943 ..Default::default()
944 })
945 .await
946 .unwrap_err();
947 assert!(matches!(no_target, BenchError::Other(_)));
948
949 let no_spec = run_owasp(CloudOwaspInputs {
950 target_url: "https://example.com".to_string(),
951 ..Default::default()
952 })
953 .await
954 .unwrap_err();
955 assert!(matches!(no_spec, BenchError::Other(_)));
956 }
957
958 #[tokio::test]
959 async fn run_security_rejects_missing_inputs() {
960 let err = run_security(CloudSecurityInputs::default()).await.unwrap_err();
961 assert!(matches!(err, BenchError::Other(_)));
962 }
963
964 #[tokio::test]
965 async fn run_wafbench_rejects_missing_rules_dir() {
966 let err = run_wafbench(CloudWafBenchInputs {
967 spec_bytes: br#"{"openapi":"3.0.0"}"#.to_vec(),
968 target_url: "https://example.com".to_string(),
969 ..Default::default()
970 })
971 .await
972 .unwrap_err();
973 let BenchError::Other(msg) = err else {
974 panic!("expected BenchError::Other");
975 };
976 assert!(msg.contains("rules_dir"), "got: {msg}");
977 }
978
979 #[tokio::test]
980 async fn run_crud_flow_rejects_missing_inputs() {
981 let err = run_crud_flow(CloudCrudFlowInputs::default()).await.unwrap_err();
982 assert!(matches!(err, BenchError::Other(_)));
983 }
984}