1use crate::crud_flow::{CrudFlowConfig, CrudFlowDetector};
4use crate::data_driven::{DataDistribution, DataDrivenConfig, DataDrivenGenerator, DataMapping};
5use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
6use crate::error::{BenchError, Result};
7use crate::executor::K6Executor;
8use crate::invalid_data::{InvalidDataConfig, InvalidDataGenerator};
9use crate::k6_gen::{K6Config, K6ScriptGenerator};
10use crate::mock_integration::{
11 MockIntegrationConfig, MockIntegrationGenerator, MockServerDetector,
12};
13use crate::owasp_api::{OwaspApiConfig, OwaspApiGenerator, OwaspCategory, ReportFormat};
14use crate::parallel_executor::{AggregatedResults, ParallelExecutor};
15use crate::parallel_requests::{ParallelConfig, ParallelRequestGenerator};
16use crate::param_overrides::ParameterOverrides;
17use crate::reporter::TerminalReporter;
18use crate::request_gen::RequestGenerator;
19use crate::scenarios::LoadScenario;
20use crate::security_payloads::{
21 SecurityCategory, SecurityPayload, SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
22};
23use crate::spec_dependencies::{
24 topological_sort, DependencyDetector, ExtractedValues, SpecDependencyConfig,
25};
26use crate::spec_parser::SpecParser;
27use crate::target_parser::parse_targets_file;
28use crate::wafbench::WafBenchLoader;
29use mockforge_core::openapi::multi_spec::{
30 load_specs_from_directory, load_specs_from_files, merge_specs, ConflictStrategy,
31};
32use mockforge_core::openapi::spec::OpenApiSpec;
33use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36
37pub struct BenchCommand {
39 pub spec: Vec<PathBuf>,
41 pub spec_dir: Option<PathBuf>,
43 pub merge_conflicts: String,
45 pub spec_mode: String,
47 pub dependency_config: Option<PathBuf>,
49 pub target: String,
50 pub base_path: Option<String>,
53 pub duration: String,
54 pub vus: u32,
55 pub scenario: String,
56 pub operations: Option<String>,
57 pub exclude_operations: Option<String>,
61 pub auth: Option<String>,
62 pub headers: Option<String>,
63 pub output: PathBuf,
64 pub generate_only: bool,
65 pub script_output: Option<PathBuf>,
66 pub threshold_percentile: String,
67 pub threshold_ms: u64,
68 pub max_error_rate: f64,
69 pub verbose: bool,
70 pub skip_tls_verify: bool,
71 pub targets_file: Option<PathBuf>,
73 pub max_concurrency: Option<u32>,
75 pub results_format: String,
77 pub params_file: Option<PathBuf>,
82
83 pub crud_flow: bool,
86 pub flow_config: Option<PathBuf>,
88 pub extract_fields: Option<String>,
90
91 pub parallel_create: Option<u32>,
94
95 pub data_file: Option<PathBuf>,
98 pub data_distribution: String,
100 pub data_mappings: Option<String>,
102 pub per_uri_control: bool,
104
105 pub error_rate: Option<f64>,
108 pub error_types: Option<String>,
110
111 pub security_test: bool,
114 pub security_payloads: Option<PathBuf>,
116 pub security_categories: Option<String>,
118 pub security_target_fields: Option<String>,
120
121 pub wafbench_dir: Option<String>,
124 pub wafbench_cycle_all: bool,
126
127 pub conformance: bool,
130 pub conformance_api_key: Option<String>,
132 pub conformance_basic_auth: Option<String>,
134 pub conformance_report: PathBuf,
136 pub conformance_categories: Option<String>,
138 pub conformance_report_format: String,
140 pub conformance_headers: Vec<String>,
143 pub conformance_all_operations: bool,
146 pub conformance_custom: Option<PathBuf>,
148 pub conformance_delay_ms: u64,
151 pub use_k6: bool,
153
154 pub owasp_api_top10: bool,
157 pub owasp_categories: Option<String>,
159 pub owasp_auth_header: String,
161 pub owasp_auth_token: Option<String>,
163 pub owasp_admin_paths: Option<PathBuf>,
165 pub owasp_id_fields: Option<String>,
167 pub owasp_report: Option<PathBuf>,
169 pub owasp_report_format: String,
171 pub owasp_iterations: u32,
173}
174
175impl BenchCommand {
176 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
178 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
179
180 if !self.spec.is_empty() {
182 let specs = load_specs_from_files(self.spec.clone())
183 .await
184 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
185 all_specs.extend(specs);
186 }
187
188 if let Some(spec_dir) = &self.spec_dir {
190 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
191 BenchError::Other(format!("Failed to load specs from directory: {}", e))
192 })?;
193 all_specs.extend(dir_specs);
194 }
195
196 if all_specs.is_empty() {
197 return Err(BenchError::Other(
198 "No spec files provided. Use --spec or --spec-dir.".to_string(),
199 ));
200 }
201
202 if all_specs.len() == 1 {
204 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
206 }
207
208 let conflict_strategy = match self.merge_conflicts.as_str() {
210 "first" => ConflictStrategy::First,
211 "last" => ConflictStrategy::Last,
212 _ => ConflictStrategy::Error,
213 };
214
215 merge_specs(all_specs, conflict_strategy)
216 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
217 }
218
219 fn get_spec_display_name(&self) -> String {
221 if self.spec.len() == 1 {
222 self.spec[0].to_string_lossy().to_string()
223 } else if !self.spec.is_empty() {
224 format!("{} spec files", self.spec.len())
225 } else if let Some(dir) = &self.spec_dir {
226 format!("specs from {}", dir.display())
227 } else {
228 "no specs".to_string()
229 }
230 }
231
232 pub async fn execute(&self) -> Result<()> {
234 if let Some(targets_file) = &self.targets_file {
236 if self.conformance {
237 TerminalReporter::print_warning(
238 "--conformance is not yet supported with --targets-file (multi-target mode). \
239 Running load test instead. To run conformance tests against multiple targets, \
240 run separate `mockforge bench --conformance --target <url>` commands for each target.",
241 );
242 }
243 return self.execute_multi_target(targets_file).await;
244 }
245
246 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
248 return self.execute_sequential_specs().await;
249 }
250
251 TerminalReporter::print_header(
254 &self.get_spec_display_name(),
255 &self.target,
256 0, &self.scenario,
258 Self::parse_duration(&self.duration)?,
259 );
260
261 if !K6Executor::is_k6_installed() {
263 TerminalReporter::print_error("k6 is not installed");
264 TerminalReporter::print_warning(
265 "Install k6 from: https://k6.io/docs/get-started/installation/",
266 );
267 return Err(BenchError::K6NotFound);
268 }
269
270 if self.conformance {
272 return self.execute_conformance_test().await;
273 }
274
275 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
277 let merged_spec = self.load_and_merge_specs().await?;
278 let parser = SpecParser::from_spec(merged_spec);
279 if self.spec.len() > 1 || self.spec_dir.is_some() {
280 TerminalReporter::print_success(&format!(
281 "Loaded and merged {} specification(s)",
282 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
283 ));
284 } else {
285 TerminalReporter::print_success("Specification loaded");
286 }
287
288 let mock_config = self.build_mock_config().await;
290 if mock_config.is_mock_server {
291 TerminalReporter::print_progress("Mock server integration enabled");
292 }
293
294 if self.crud_flow {
296 return self.execute_crud_flow(&parser).await;
297 }
298
299 if self.owasp_api_top10 {
301 return self.execute_owasp_test(&parser).await;
302 }
303
304 TerminalReporter::print_progress("Extracting API operations...");
306 let mut operations = if let Some(filter) = &self.operations {
307 parser.filter_operations(filter)?
308 } else {
309 parser.get_operations()
310 };
311
312 if let Some(exclude) = &self.exclude_operations {
314 let before_count = operations.len();
315 operations = parser.exclude_operations(operations, exclude)?;
316 let excluded_count = before_count - operations.len();
317 if excluded_count > 0 {
318 TerminalReporter::print_progress(&format!(
319 "Excluded {} operations matching '{}'",
320 excluded_count, exclude
321 ));
322 }
323 }
324
325 if operations.is_empty() {
326 return Err(BenchError::Other("No operations found in spec".to_string()));
327 }
328
329 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
330
331 let param_overrides = if let Some(params_file) = &self.params_file {
333 TerminalReporter::print_progress("Loading parameter overrides...");
334 let overrides = ParameterOverrides::from_file(params_file)?;
335 TerminalReporter::print_success(&format!(
336 "Loaded parameter overrides ({} operation-specific, {} defaults)",
337 overrides.operations.len(),
338 if overrides.defaults.is_empty() { 0 } else { 1 }
339 ));
340 Some(overrides)
341 } else {
342 None
343 };
344
345 TerminalReporter::print_progress("Generating request templates...");
347 let templates: Vec<_> = operations
348 .iter()
349 .map(|op| {
350 let op_overrides = param_overrides.as_ref().map(|po| {
351 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
352 });
353 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
354 })
355 .collect::<Result<Vec<_>>>()?;
356 TerminalReporter::print_success("Request templates generated");
357
358 let custom_headers = self.parse_headers()?;
360
361 let base_path = self.resolve_base_path(&parser);
363 if let Some(ref bp) = base_path {
364 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
365 }
366
367 TerminalReporter::print_progress("Generating k6 load test script...");
369 let scenario =
370 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
371
372 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
373
374 let k6_config = K6Config {
375 target_url: self.target.clone(),
376 base_path,
377 scenario,
378 duration_secs: Self::parse_duration(&self.duration)?,
379 max_vus: self.vus,
380 threshold_percentile: self.threshold_percentile.clone(),
381 threshold_ms: self.threshold_ms,
382 max_error_rate: self.max_error_rate,
383 auth_header: self.auth.clone(),
384 custom_headers,
385 skip_tls_verify: self.skip_tls_verify,
386 security_testing_enabled,
387 };
388
389 let generator = K6ScriptGenerator::new(k6_config, templates);
390 let mut script = generator.generate()?;
391 TerminalReporter::print_success("k6 script generated");
392
393 let has_advanced_features = self.data_file.is_some()
395 || self.error_rate.is_some()
396 || self.security_test
397 || self.parallel_create.is_some()
398 || self.wafbench_dir.is_some();
399
400 if has_advanced_features {
402 script = self.generate_enhanced_script(&script)?;
403 }
404
405 if mock_config.is_mock_server {
407 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
408 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
409 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
410
411 if let Some(import_end) = script.find("export const options") {
413 script.insert_str(
414 import_end,
415 &format!(
416 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
417 helper_code, setup_code, teardown_code
418 ),
419 );
420 }
421 }
422
423 TerminalReporter::print_progress("Validating k6 script...");
425 let validation_errors = K6ScriptGenerator::validate_script(&script);
426 if !validation_errors.is_empty() {
427 TerminalReporter::print_error("Script validation failed");
428 for error in &validation_errors {
429 eprintln!(" {}", error);
430 }
431 return Err(BenchError::Other(format!(
432 "Generated k6 script has {} validation error(s). Please check the output above.",
433 validation_errors.len()
434 )));
435 }
436 TerminalReporter::print_success("Script validation passed");
437
438 let script_path = if let Some(output) = &self.script_output {
440 output.clone()
441 } else {
442 self.output.join("k6-script.js")
443 };
444
445 if let Some(parent) = script_path.parent() {
446 std::fs::create_dir_all(parent)?;
447 }
448 std::fs::write(&script_path, &script)?;
449 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
450
451 if self.generate_only {
453 println!("\nScript generated successfully. Run it with:");
454 println!(" k6 run {}", script_path.display());
455 return Ok(());
456 }
457
458 TerminalReporter::print_progress("Executing load test...");
460 let executor = K6Executor::new()?;
461
462 std::fs::create_dir_all(&self.output)?;
463
464 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
465
466 let duration_secs = Self::parse_duration(&self.duration)?;
468 TerminalReporter::print_summary(&results, duration_secs);
469
470 println!("\nResults saved to: {}", self.output.display());
471
472 Ok(())
473 }
474
475 async fn execute_multi_target(&self, targets_file: &Path) -> Result<()> {
477 TerminalReporter::print_progress("Parsing targets file...");
478 let targets = parse_targets_file(targets_file)?;
479 let num_targets = targets.len();
480 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
481
482 if targets.is_empty() {
483 return Err(BenchError::Other("No targets found in file".to_string()));
484 }
485
486 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
488 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
492 &self.get_spec_display_name(),
493 &format!("{} targets", num_targets),
494 0,
495 &self.scenario,
496 Self::parse_duration(&self.duration)?,
497 );
498
499 let executor = ParallelExecutor::new(
501 BenchCommand {
502 spec: self.spec.clone(),
504 spec_dir: self.spec_dir.clone(),
505 merge_conflicts: self.merge_conflicts.clone(),
506 spec_mode: self.spec_mode.clone(),
507 dependency_config: self.dependency_config.clone(),
508 target: self.target.clone(), base_path: self.base_path.clone(),
510 duration: self.duration.clone(),
511 vus: self.vus,
512 scenario: self.scenario.clone(),
513 operations: self.operations.clone(),
514 exclude_operations: self.exclude_operations.clone(),
515 auth: self.auth.clone(),
516 headers: self.headers.clone(),
517 output: self.output.clone(),
518 generate_only: self.generate_only,
519 script_output: self.script_output.clone(),
520 threshold_percentile: self.threshold_percentile.clone(),
521 threshold_ms: self.threshold_ms,
522 max_error_rate: self.max_error_rate,
523 verbose: self.verbose,
524 skip_tls_verify: self.skip_tls_verify,
525 targets_file: None,
526 max_concurrency: None,
527 results_format: self.results_format.clone(),
528 params_file: self.params_file.clone(),
529 crud_flow: self.crud_flow,
530 flow_config: self.flow_config.clone(),
531 extract_fields: self.extract_fields.clone(),
532 parallel_create: self.parallel_create,
533 data_file: self.data_file.clone(),
534 data_distribution: self.data_distribution.clone(),
535 data_mappings: self.data_mappings.clone(),
536 per_uri_control: self.per_uri_control,
537 error_rate: self.error_rate,
538 error_types: self.error_types.clone(),
539 security_test: self.security_test,
540 security_payloads: self.security_payloads.clone(),
541 security_categories: self.security_categories.clone(),
542 security_target_fields: self.security_target_fields.clone(),
543 wafbench_dir: self.wafbench_dir.clone(),
544 wafbench_cycle_all: self.wafbench_cycle_all,
545 owasp_api_top10: self.owasp_api_top10,
546 owasp_categories: self.owasp_categories.clone(),
547 owasp_auth_header: self.owasp_auth_header.clone(),
548 owasp_auth_token: self.owasp_auth_token.clone(),
549 owasp_admin_paths: self.owasp_admin_paths.clone(),
550 owasp_id_fields: self.owasp_id_fields.clone(),
551 owasp_report: self.owasp_report.clone(),
552 owasp_report_format: self.owasp_report_format.clone(),
553 owasp_iterations: self.owasp_iterations,
554 conformance: false,
555 conformance_api_key: None,
556 conformance_basic_auth: None,
557 conformance_report: PathBuf::from("conformance-report.json"),
558 conformance_categories: None,
559 conformance_report_format: "json".to_string(),
560 conformance_headers: vec![],
561 conformance_all_operations: false,
562 conformance_custom: None,
563 conformance_delay_ms: 0,
564 use_k6: false,
565 },
566 targets,
567 max_concurrency,
568 );
569
570 let aggregated_results = executor.execute_all().await?;
572
573 self.report_multi_target_results(&aggregated_results)?;
575
576 Ok(())
577 }
578
579 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
581 TerminalReporter::print_multi_target_summary(results);
583
584 if self.results_format == "aggregated" || self.results_format == "both" {
586 let summary_path = self.output.join("aggregated_summary.json");
587 let summary_json = serde_json::json!({
588 "total_targets": results.total_targets,
589 "successful_targets": results.successful_targets,
590 "failed_targets": results.failed_targets,
591 "aggregated_metrics": {
592 "total_requests": results.aggregated_metrics.total_requests,
593 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
594 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
595 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
596 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
597 "error_rate": results.aggregated_metrics.error_rate,
598 "total_rps": results.aggregated_metrics.total_rps,
599 "avg_rps": results.aggregated_metrics.avg_rps,
600 "total_vus_max": results.aggregated_metrics.total_vus_max,
601 },
602 "target_results": results.target_results.iter().map(|r| {
603 serde_json::json!({
604 "target_url": r.target_url,
605 "target_index": r.target_index,
606 "success": r.success,
607 "error": r.error,
608 "total_requests": r.results.total_requests,
609 "failed_requests": r.results.failed_requests,
610 "avg_duration_ms": r.results.avg_duration_ms,
611 "min_duration_ms": r.results.min_duration_ms,
612 "med_duration_ms": r.results.med_duration_ms,
613 "p90_duration_ms": r.results.p90_duration_ms,
614 "p95_duration_ms": r.results.p95_duration_ms,
615 "p99_duration_ms": r.results.p99_duration_ms,
616 "max_duration_ms": r.results.max_duration_ms,
617 "rps": r.results.rps,
618 "vus_max": r.results.vus_max,
619 "output_dir": r.output_dir.to_string_lossy(),
620 })
621 }).collect::<Vec<_>>(),
622 });
623
624 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
625 TerminalReporter::print_success(&format!(
626 "Aggregated summary saved to: {}",
627 summary_path.display()
628 ));
629 }
630
631 println!("\nResults saved to: {}", self.output.display());
632 println!(" - Per-target results: {}", self.output.join("target_*").display());
633 if self.results_format == "aggregated" || self.results_format == "both" {
634 println!(
635 " - Aggregated summary: {}",
636 self.output.join("aggregated_summary.json").display()
637 );
638 }
639
640 Ok(())
641 }
642
643 pub fn parse_duration(duration: &str) -> Result<u64> {
645 let duration = duration.trim();
646
647 if let Some(secs) = duration.strip_suffix('s') {
648 secs.parse::<u64>()
649 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
650 } else if let Some(mins) = duration.strip_suffix('m') {
651 mins.parse::<u64>()
652 .map(|m| m * 60)
653 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
654 } else if let Some(hours) = duration.strip_suffix('h') {
655 hours
656 .parse::<u64>()
657 .map(|h| h * 3600)
658 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
659 } else {
660 duration
662 .parse::<u64>()
663 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
664 }
665 }
666
667 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
669 let mut headers = HashMap::new();
670
671 if let Some(header_str) = &self.headers {
672 for pair in header_str.split(',') {
673 let parts: Vec<&str> = pair.splitn(2, ':').collect();
674 if parts.len() != 2 {
675 return Err(BenchError::Other(format!(
676 "Invalid header format: '{}'. Expected 'Key:Value'",
677 pair
678 )));
679 }
680 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
681 }
682 }
683
684 Ok(headers)
685 }
686
687 fn parse_extracted_values(output_dir: &Path) -> Result<ExtractedValues> {
688 let extracted_path = output_dir.join("extracted_values.json");
689 if !extracted_path.exists() {
690 return Ok(ExtractedValues::new());
691 }
692
693 let content = std::fs::read_to_string(&extracted_path)
694 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
695 let parsed: serde_json::Value = serde_json::from_str(&content)
696 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
697
698 let mut extracted = ExtractedValues::new();
699 if let Some(values) = parsed.as_object() {
700 for (key, value) in values {
701 extracted.set(key.clone(), value.clone());
702 }
703 }
704
705 Ok(extracted)
706 }
707
708 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
717 if let Some(cli_base_path) = &self.base_path {
719 if cli_base_path.is_empty() {
720 return None;
722 }
723 return Some(cli_base_path.clone());
724 }
725
726 parser.get_base_path()
728 }
729
730 async fn build_mock_config(&self) -> MockIntegrationConfig {
732 if MockServerDetector::looks_like_mock_server(&self.target) {
734 if let Ok(info) = MockServerDetector::detect(&self.target).await {
736 if info.is_mockforge {
737 TerminalReporter::print_success(&format!(
738 "Detected MockForge server (version: {})",
739 info.version.as_deref().unwrap_or("unknown")
740 ));
741 return MockIntegrationConfig::mock_server();
742 }
743 }
744 }
745 MockIntegrationConfig::real_api()
746 }
747
748 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
750 if !self.crud_flow {
751 return None;
752 }
753
754 if let Some(config_path) = &self.flow_config {
756 match CrudFlowConfig::from_file(config_path) {
757 Ok(config) => return Some(config),
758 Err(e) => {
759 TerminalReporter::print_warning(&format!(
760 "Failed to load flow config: {}. Using auto-detection.",
761 e
762 ));
763 }
764 }
765 }
766
767 let extract_fields = self
769 .extract_fields
770 .as_ref()
771 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
772 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
773
774 Some(CrudFlowConfig {
775 flows: Vec::new(), default_extract_fields: extract_fields,
777 })
778 }
779
780 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
782 let data_file = self.data_file.as_ref()?;
783
784 let distribution = DataDistribution::from_str(&self.data_distribution)
785 .unwrap_or(DataDistribution::UniquePerVu);
786
787 let mappings = self
788 .data_mappings
789 .as_ref()
790 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
791 .unwrap_or_default();
792
793 Some(DataDrivenConfig {
794 file_path: data_file.to_string_lossy().to_string(),
795 distribution,
796 mappings,
797 csv_has_header: true,
798 per_uri_control: self.per_uri_control,
799 per_uri_columns: crate::data_driven::PerUriColumns::default(),
800 })
801 }
802
803 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
805 let error_rate = self.error_rate?;
806
807 let error_types = self
808 .error_types
809 .as_ref()
810 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
811 .unwrap_or_default();
812
813 Some(InvalidDataConfig {
814 error_rate,
815 error_types,
816 target_fields: Vec::new(),
817 })
818 }
819
820 fn build_security_config(&self) -> Option<SecurityTestConfig> {
822 if !self.security_test {
823 return None;
824 }
825
826 let categories = self
827 .security_categories
828 .as_ref()
829 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
830 .unwrap_or_else(|| {
831 let mut default = HashSet::new();
832 default.insert(SecurityCategory::SqlInjection);
833 default.insert(SecurityCategory::Xss);
834 default
835 });
836
837 let target_fields = self
838 .security_target_fields
839 .as_ref()
840 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
841 .unwrap_or_default();
842
843 let custom_payloads_file =
844 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
845
846 Some(SecurityTestConfig {
847 enabled: true,
848 categories,
849 target_fields,
850 custom_payloads_file,
851 include_high_risk: false,
852 })
853 }
854
855 fn build_parallel_config(&self) -> Option<ParallelConfig> {
857 let count = self.parallel_create?;
858
859 Some(ParallelConfig::new(count))
860 }
861
862 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
864 let Some(ref wafbench_dir) = self.wafbench_dir else {
865 return Vec::new();
866 };
867
868 let mut loader = WafBenchLoader::new();
869
870 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
871 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
872 return Vec::new();
873 }
874
875 let stats = loader.stats();
876
877 if stats.files_processed == 0 {
878 TerminalReporter::print_warning(&format!(
879 "No WAFBench YAML files found matching '{}'",
880 wafbench_dir
881 ));
882 if !stats.parse_errors.is_empty() {
884 TerminalReporter::print_warning("Some files were found but failed to parse:");
885 for error in &stats.parse_errors {
886 TerminalReporter::print_warning(&format!(" - {}", error));
887 }
888 }
889 return Vec::new();
890 }
891
892 TerminalReporter::print_progress(&format!(
893 "Loaded {} WAFBench files, {} test cases, {} payloads",
894 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
895 ));
896
897 for (category, count) in &stats.by_category {
899 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
900 }
901
902 for error in &stats.parse_errors {
904 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
905 }
906
907 loader.to_security_payloads()
908 }
909
910 pub(crate) fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
912 let mut enhanced_script = base_script.to_string();
913 let mut additional_code = String::new();
914
915 if let Some(config) = self.build_data_driven_config() {
917 TerminalReporter::print_progress("Adding data-driven testing support...");
918 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
919 additional_code.push('\n');
920 TerminalReporter::print_success("Data-driven testing enabled");
921 }
922
923 if let Some(config) = self.build_invalid_data_config() {
925 TerminalReporter::print_progress("Adding invalid data testing support...");
926 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
927 additional_code.push('\n');
928 additional_code
929 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
930 additional_code.push('\n');
931 additional_code
932 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
933 additional_code.push('\n');
934 TerminalReporter::print_success(&format!(
935 "Invalid data testing enabled ({}% error rate)",
936 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
937 ));
938 }
939
940 let security_config = self.build_security_config();
942 let wafbench_payloads = self.load_wafbench_payloads();
943 let security_requested = security_config.is_some() || self.wafbench_dir.is_some();
944
945 if security_config.is_some() || !wafbench_payloads.is_empty() {
946 TerminalReporter::print_progress("Adding security testing support...");
947
948 let mut payload_list: Vec<SecurityPayload> = Vec::new();
950
951 if let Some(ref config) = security_config {
952 payload_list.extend(SecurityPayloads::get_payloads(config));
953 }
954
955 if !wafbench_payloads.is_empty() {
957 TerminalReporter::print_progress(&format!(
958 "Loading {} WAFBench attack patterns...",
959 wafbench_payloads.len()
960 ));
961 payload_list.extend(wafbench_payloads);
962 }
963
964 let target_fields =
965 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
966
967 additional_code.push_str(&SecurityTestGenerator::generate_payload_selection(
968 &payload_list,
969 self.wafbench_cycle_all,
970 ));
971 additional_code.push('\n');
972 additional_code
973 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
974 additional_code.push('\n');
975 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
976 additional_code.push('\n');
977
978 let mode = if self.wafbench_cycle_all {
979 "cycle-all"
980 } else {
981 "random"
982 };
983 TerminalReporter::print_success(&format!(
984 "Security testing enabled ({} payloads, {} mode)",
985 payload_list.len(),
986 mode
987 ));
988 } else if security_requested {
989 TerminalReporter::print_warning(
993 "Security testing was requested but no payloads were loaded. \
994 Ensure --wafbench-dir points to valid CRS YAML files or add --security-test.",
995 );
996 additional_code
997 .push_str(&SecurityTestGenerator::generate_payload_selection(&[], false));
998 additional_code.push('\n');
999 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1000 additional_code.push('\n');
1001 }
1002
1003 if let Some(config) = self.build_parallel_config() {
1005 TerminalReporter::print_progress("Adding parallel execution support...");
1006 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
1007 additional_code.push('\n');
1008 TerminalReporter::print_success(&format!(
1009 "Parallel execution enabled (count: {})",
1010 config.count
1011 ));
1012 }
1013
1014 if !additional_code.is_empty() {
1016 if let Some(import_end) = enhanced_script.find("export const options") {
1018 enhanced_script.insert_str(
1019 import_end,
1020 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1021 );
1022 }
1023 }
1024
1025 Ok(enhanced_script)
1026 }
1027
1028 async fn execute_sequential_specs(&self) -> Result<()> {
1030 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
1031
1032 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
1034
1035 if !self.spec.is_empty() {
1036 let specs = load_specs_from_files(self.spec.clone())
1037 .await
1038 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
1039 all_specs.extend(specs);
1040 }
1041
1042 if let Some(spec_dir) = &self.spec_dir {
1043 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
1044 BenchError::Other(format!("Failed to load specs from directory: {}", e))
1045 })?;
1046 all_specs.extend(dir_specs);
1047 }
1048
1049 if all_specs.is_empty() {
1050 return Err(BenchError::Other(
1051 "No spec files found for sequential execution".to_string(),
1052 ));
1053 }
1054
1055 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
1056
1057 let execution_order = if let Some(config_path) = &self.dependency_config {
1059 TerminalReporter::print_progress("Loading dependency configuration...");
1060 let config = SpecDependencyConfig::from_file(config_path)?;
1061
1062 if !config.disable_auto_detect && config.execution_order.is_empty() {
1063 self.detect_and_sort_specs(&all_specs)?
1065 } else {
1066 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
1068 }
1069 } else {
1070 self.detect_and_sort_specs(&all_specs)?
1072 };
1073
1074 TerminalReporter::print_success(&format!(
1075 "Execution order: {}",
1076 execution_order
1077 .iter()
1078 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
1079 .collect::<Vec<_>>()
1080 .join(" → ")
1081 ));
1082
1083 let mut extracted_values = ExtractedValues::new();
1085 let total_specs = execution_order.len();
1086
1087 for (index, spec_path) in execution_order.iter().enumerate() {
1088 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
1089
1090 TerminalReporter::print_progress(&format!(
1091 "[{}/{}] Executing spec: {}",
1092 index + 1,
1093 total_specs,
1094 spec_name
1095 ));
1096
1097 let spec = all_specs
1099 .iter()
1100 .find(|(p, _)| {
1101 p == spec_path
1102 || p.file_name() == spec_path.file_name()
1103 || p.file_name() == Some(spec_path.as_os_str())
1104 })
1105 .map(|(_, s)| s.clone())
1106 .ok_or_else(|| {
1107 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
1108 })?;
1109
1110 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
1112
1113 extracted_values.merge(&new_values);
1115
1116 TerminalReporter::print_success(&format!(
1117 "[{}/{}] Completed: {} (extracted {} values)",
1118 index + 1,
1119 total_specs,
1120 spec_name,
1121 new_values.values.len()
1122 ));
1123 }
1124
1125 TerminalReporter::print_success(&format!(
1126 "Sequential execution complete: {} specs executed",
1127 total_specs
1128 ));
1129
1130 Ok(())
1131 }
1132
1133 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1135 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1136
1137 let mut detector = DependencyDetector::new();
1138 let dependencies = detector.detect_dependencies(specs);
1139
1140 if dependencies.is_empty() {
1141 TerminalReporter::print_progress("No dependencies detected, using file order");
1142 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1143 }
1144
1145 TerminalReporter::print_progress(&format!(
1146 "Detected {} cross-spec dependencies",
1147 dependencies.len()
1148 ));
1149
1150 for dep in &dependencies {
1151 TerminalReporter::print_progress(&format!(
1152 " {} → {} (via field '{}')",
1153 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1154 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1155 dep.field_name
1156 ));
1157 }
1158
1159 topological_sort(specs, &dependencies)
1160 }
1161
1162 async fn execute_single_spec(
1164 &self,
1165 spec: &OpenApiSpec,
1166 spec_name: &str,
1167 _external_values: &ExtractedValues,
1168 ) -> Result<ExtractedValues> {
1169 let parser = SpecParser::from_spec(spec.clone());
1170
1171 if self.crud_flow {
1173 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1175 } else {
1176 self.execute_standard_spec(&parser, spec_name).await?;
1178 Ok(ExtractedValues::new())
1179 }
1180 }
1181
1182 async fn execute_crud_flow_with_extraction(
1184 &self,
1185 parser: &SpecParser,
1186 spec_name: &str,
1187 ) -> Result<ExtractedValues> {
1188 let operations = parser.get_operations();
1189 let flows = CrudFlowDetector::detect_flows(&operations);
1190
1191 if flows.is_empty() {
1192 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1193 return Ok(ExtractedValues::new());
1194 }
1195
1196 TerminalReporter::print_progress(&format!(
1197 " {} CRUD flow(s) in {}",
1198 flows.len(),
1199 spec_name
1200 ));
1201
1202 let mut handlebars = handlebars::Handlebars::new();
1204 handlebars.register_helper(
1206 "json",
1207 Box::new(
1208 |h: &handlebars::Helper,
1209 _: &handlebars::Handlebars,
1210 _: &handlebars::Context,
1211 _: &mut handlebars::RenderContext,
1212 out: &mut dyn handlebars::Output|
1213 -> handlebars::HelperResult {
1214 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1215 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1216 Ok(())
1217 },
1218 ),
1219 );
1220 let template = include_str!("templates/k6_crud_flow.hbs");
1221 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1222
1223 let custom_headers = self.parse_headers()?;
1224 let config = self.build_crud_flow_config().unwrap_or_default();
1225
1226 let param_overrides = if let Some(params_file) = &self.params_file {
1228 let overrides = ParameterOverrides::from_file(params_file)?;
1229 Some(overrides)
1230 } else {
1231 None
1232 };
1233
1234 let duration_secs = Self::parse_duration(&self.duration)?;
1236 let scenario =
1237 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1238 let stages = scenario.generate_stages(duration_secs, self.vus);
1239
1240 let api_base_path = self.resolve_base_path(parser);
1242
1243 let mut all_headers = custom_headers.clone();
1245 if let Some(auth) = &self.auth {
1246 all_headers.insert("Authorization".to_string(), auth.clone());
1247 }
1248 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1249
1250 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1252
1253 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1254 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1255 serde_json::json!({
1256 "name": sanitized_name.clone(),
1257 "display_name": f.name,
1258 "base_path": f.base_path,
1259 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1260 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1262 let method_raw = if !parts.is_empty() {
1263 parts[0].to_uppercase()
1264 } else {
1265 "GET".to_string()
1266 };
1267 let method = if !parts.is_empty() {
1268 let m = parts[0].to_lowercase();
1269 if m == "delete" { "del".to_string() } else { m }
1271 } else {
1272 "get".to_string()
1273 };
1274 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1275 let path = if let Some(ref bp) = api_base_path {
1277 format!("{}{}", bp, raw_path)
1278 } else {
1279 raw_path.to_string()
1280 };
1281 let is_get_or_head = method == "get" || method == "head";
1282 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1284
1285 let body_value = if has_body {
1287 param_overrides.as_ref()
1288 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1289 .and_then(|oo| oo.body)
1290 .unwrap_or_else(|| serde_json::json!({}))
1291 } else {
1292 serde_json::json!({})
1293 };
1294
1295 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1297
1298 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1300 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1301
1302 serde_json::json!({
1303 "operation": s.operation,
1304 "method": method,
1305 "path": path,
1306 "extract": s.extract,
1307 "use_values": s.use_values,
1308 "use_body": s.use_body,
1309 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1310 "inject_attacks": s.inject_attacks,
1311 "attack_types": s.attack_types,
1312 "description": s.description,
1313 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1314 "is_get_or_head": is_get_or_head,
1315 "has_body": has_body,
1316 "body": processed_body.value,
1317 "body_is_dynamic": body_is_dynamic,
1318 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1319 })
1320 }).collect::<Vec<_>>(),
1321 })
1322 }).collect();
1323
1324 for flow_data in &flows_data {
1326 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1327 for step in steps {
1328 if let Some(placeholders_arr) =
1329 step.get("_placeholders").and_then(|p| p.as_array())
1330 {
1331 for p_str in placeholders_arr {
1332 if let Some(p_name) = p_str.as_str() {
1333 match p_name {
1334 "VU" => {
1335 all_placeholders.insert(DynamicPlaceholder::VU);
1336 }
1337 "Iteration" => {
1338 all_placeholders.insert(DynamicPlaceholder::Iteration);
1339 }
1340 "Timestamp" => {
1341 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1342 }
1343 "UUID" => {
1344 all_placeholders.insert(DynamicPlaceholder::UUID);
1345 }
1346 "Random" => {
1347 all_placeholders.insert(DynamicPlaceholder::Random);
1348 }
1349 "Counter" => {
1350 all_placeholders.insert(DynamicPlaceholder::Counter);
1351 }
1352 "Date" => {
1353 all_placeholders.insert(DynamicPlaceholder::Date);
1354 }
1355 "VuIter" => {
1356 all_placeholders.insert(DynamicPlaceholder::VuIter);
1357 }
1358 _ => {}
1359 }
1360 }
1361 }
1362 }
1363 }
1364 }
1365 }
1366
1367 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1369 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1370
1371 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1373
1374 let data = serde_json::json!({
1375 "base_url": self.target,
1376 "flows": flows_data,
1377 "extract_fields": config.default_extract_fields,
1378 "duration_secs": duration_secs,
1379 "max_vus": self.vus,
1380 "auth_header": self.auth,
1381 "custom_headers": custom_headers,
1382 "skip_tls_verify": self.skip_tls_verify,
1383 "stages": stages.iter().map(|s| serde_json::json!({
1385 "duration": s.duration,
1386 "target": s.target,
1387 })).collect::<Vec<_>>(),
1388 "threshold_percentile": self.threshold_percentile,
1389 "threshold_ms": self.threshold_ms,
1390 "max_error_rate": self.max_error_rate,
1391 "headers": headers_json,
1392 "dynamic_imports": required_imports,
1393 "dynamic_globals": required_globals,
1394 "extracted_values_output_path": output_dir.join("extracted_values.json").to_string_lossy(),
1395 "security_testing_enabled": security_testing_enabled,
1397 "has_custom_headers": !custom_headers.is_empty(),
1398 });
1399
1400 let mut script = handlebars
1401 .render_template(template, &data)
1402 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1403
1404 if security_testing_enabled {
1406 script = self.generate_enhanced_script(&script)?;
1407 }
1408
1409 let script_path =
1411 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1412
1413 std::fs::create_dir_all(self.output.clone())?;
1414 std::fs::write(&script_path, &script)?;
1415
1416 if !self.generate_only {
1417 let executor = K6Executor::new()?;
1418 std::fs::create_dir_all(&output_dir)?;
1419
1420 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1421
1422 let extracted = Self::parse_extracted_values(&output_dir)?;
1423 TerminalReporter::print_progress(&format!(
1424 " Extracted {} value(s) from {}",
1425 extracted.values.len(),
1426 spec_name
1427 ));
1428 return Ok(extracted);
1429 }
1430
1431 Ok(ExtractedValues::new())
1432 }
1433
1434 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1436 let mut operations = if let Some(filter) = &self.operations {
1437 parser.filter_operations(filter)?
1438 } else {
1439 parser.get_operations()
1440 };
1441
1442 if let Some(exclude) = &self.exclude_operations {
1443 operations = parser.exclude_operations(operations, exclude)?;
1444 }
1445
1446 if operations.is_empty() {
1447 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1448 return Ok(());
1449 }
1450
1451 TerminalReporter::print_progress(&format!(
1452 " {} operations in {}",
1453 operations.len(),
1454 spec_name
1455 ));
1456
1457 let templates: Vec<_> = operations
1459 .iter()
1460 .map(RequestGenerator::generate_template)
1461 .collect::<Result<Vec<_>>>()?;
1462
1463 let custom_headers = self.parse_headers()?;
1465
1466 let base_path = self.resolve_base_path(parser);
1468
1469 let scenario =
1471 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1472
1473 let security_testing_enabled = self.security_test || self.wafbench_dir.is_some();
1474
1475 let k6_config = K6Config {
1476 target_url: self.target.clone(),
1477 base_path,
1478 scenario,
1479 duration_secs: Self::parse_duration(&self.duration)?,
1480 max_vus: self.vus,
1481 threshold_percentile: self.threshold_percentile.clone(),
1482 threshold_ms: self.threshold_ms,
1483 max_error_rate: self.max_error_rate,
1484 auth_header: self.auth.clone(),
1485 custom_headers,
1486 skip_tls_verify: self.skip_tls_verify,
1487 security_testing_enabled,
1488 };
1489
1490 let generator = K6ScriptGenerator::new(k6_config, templates);
1491 let mut script = generator.generate()?;
1492
1493 let has_advanced_features = self.data_file.is_some()
1495 || self.error_rate.is_some()
1496 || self.security_test
1497 || self.parallel_create.is_some()
1498 || self.wafbench_dir.is_some();
1499
1500 if has_advanced_features {
1501 script = self.generate_enhanced_script(&script)?;
1502 }
1503
1504 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1506
1507 std::fs::create_dir_all(self.output.clone())?;
1508 std::fs::write(&script_path, &script)?;
1509
1510 if !self.generate_only {
1511 let executor = K6Executor::new()?;
1512 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1513 std::fs::create_dir_all(&output_dir)?;
1514
1515 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1516 }
1517
1518 Ok(())
1519 }
1520
1521 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1523 let config = self.build_crud_flow_config().unwrap_or_default();
1525
1526 let flows = if !config.flows.is_empty() {
1528 TerminalReporter::print_progress("Using custom flow configuration...");
1529 config.flows.clone()
1530 } else {
1531 TerminalReporter::print_progress("Detecting CRUD operations...");
1532 let operations = parser.get_operations();
1533 CrudFlowDetector::detect_flows(&operations)
1534 };
1535
1536 if flows.is_empty() {
1537 return Err(BenchError::Other(
1538 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1539 ));
1540 }
1541
1542 if config.flows.is_empty() {
1543 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1544 } else {
1545 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1546 }
1547
1548 for flow in &flows {
1549 TerminalReporter::print_progress(&format!(
1550 " - {}: {} steps",
1551 flow.name,
1552 flow.steps.len()
1553 ));
1554 }
1555
1556 let mut handlebars = handlebars::Handlebars::new();
1558 handlebars.register_helper(
1560 "json",
1561 Box::new(
1562 |h: &handlebars::Helper,
1563 _: &handlebars::Handlebars,
1564 _: &handlebars::Context,
1565 _: &mut handlebars::RenderContext,
1566 out: &mut dyn handlebars::Output|
1567 -> handlebars::HelperResult {
1568 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1569 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1570 Ok(())
1571 },
1572 ),
1573 );
1574 let template = include_str!("templates/k6_crud_flow.hbs");
1575
1576 let custom_headers = self.parse_headers()?;
1577
1578 let param_overrides = if let Some(params_file) = &self.params_file {
1580 TerminalReporter::print_progress("Loading parameter overrides...");
1581 let overrides = ParameterOverrides::from_file(params_file)?;
1582 TerminalReporter::print_success(&format!(
1583 "Loaded parameter overrides ({} operation-specific, {} defaults)",
1584 overrides.operations.len(),
1585 if overrides.defaults.is_empty() { 0 } else { 1 }
1586 ));
1587 Some(overrides)
1588 } else {
1589 None
1590 };
1591
1592 let duration_secs = Self::parse_duration(&self.duration)?;
1594 let scenario =
1595 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1596 let stages = scenario.generate_stages(duration_secs, self.vus);
1597
1598 let api_base_path = self.resolve_base_path(parser);
1600 if let Some(ref bp) = api_base_path {
1601 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1602 }
1603
1604 let mut all_headers = custom_headers.clone();
1606 if let Some(auth) = &self.auth {
1607 all_headers.insert("Authorization".to_string(), auth.clone());
1608 }
1609 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1610
1611 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1613
1614 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1615 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1617 serde_json::json!({
1618 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1621 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1622 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1624 let method_raw = if !parts.is_empty() {
1625 parts[0].to_uppercase()
1626 } else {
1627 "GET".to_string()
1628 };
1629 let method = if !parts.is_empty() {
1630 let m = parts[0].to_lowercase();
1631 if m == "delete" { "del".to_string() } else { m }
1633 } else {
1634 "get".to_string()
1635 };
1636 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1637 let path = if let Some(ref bp) = api_base_path {
1639 format!("{}{}", bp, raw_path)
1640 } else {
1641 raw_path.to_string()
1642 };
1643 let is_get_or_head = method == "get" || method == "head";
1644 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1646
1647 let body_value = if has_body {
1649 param_overrides.as_ref()
1650 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1651 .and_then(|oo| oo.body)
1652 .unwrap_or_else(|| serde_json::json!({}))
1653 } else {
1654 serde_json::json!({})
1655 };
1656
1657 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1659 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1664 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1665
1666 serde_json::json!({
1667 "operation": s.operation,
1668 "method": method,
1669 "path": path,
1670 "extract": s.extract,
1671 "use_values": s.use_values,
1672 "use_body": s.use_body,
1673 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1674 "inject_attacks": s.inject_attacks,
1675 "attack_types": s.attack_types,
1676 "description": s.description,
1677 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1678 "is_get_or_head": is_get_or_head,
1679 "has_body": has_body,
1680 "body": processed_body.value,
1681 "body_is_dynamic": body_is_dynamic,
1682 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1683 })
1684 }).collect::<Vec<_>>(),
1685 })
1686 }).collect();
1687
1688 for flow_data in &flows_data {
1690 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1691 for step in steps {
1692 if let Some(placeholders_arr) =
1693 step.get("_placeholders").and_then(|p| p.as_array())
1694 {
1695 for p_str in placeholders_arr {
1696 if let Some(p_name) = p_str.as_str() {
1697 match p_name {
1699 "VU" => {
1700 all_placeholders.insert(DynamicPlaceholder::VU);
1701 }
1702 "Iteration" => {
1703 all_placeholders.insert(DynamicPlaceholder::Iteration);
1704 }
1705 "Timestamp" => {
1706 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1707 }
1708 "UUID" => {
1709 all_placeholders.insert(DynamicPlaceholder::UUID);
1710 }
1711 "Random" => {
1712 all_placeholders.insert(DynamicPlaceholder::Random);
1713 }
1714 "Counter" => {
1715 all_placeholders.insert(DynamicPlaceholder::Counter);
1716 }
1717 "Date" => {
1718 all_placeholders.insert(DynamicPlaceholder::Date);
1719 }
1720 "VuIter" => {
1721 all_placeholders.insert(DynamicPlaceholder::VuIter);
1722 }
1723 _ => {}
1724 }
1725 }
1726 }
1727 }
1728 }
1729 }
1730 }
1731
1732 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1734 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1735
1736 let invalid_data_config = self.build_invalid_data_config();
1738 let error_injection_enabled = invalid_data_config.is_some();
1739 let error_rate = self.error_rate.unwrap_or(0.0);
1740 let error_types: Vec<String> = invalid_data_config
1741 .as_ref()
1742 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1743 .unwrap_or_default();
1744
1745 if error_injection_enabled {
1746 TerminalReporter::print_progress(&format!(
1747 "Error injection enabled ({}% rate)",
1748 (error_rate * 100.0) as u32
1749 ));
1750 }
1751
1752 let security_testing_enabled = self.wafbench_dir.is_some() || self.security_test;
1754
1755 let data = serde_json::json!({
1756 "base_url": self.target,
1757 "flows": flows_data,
1758 "extract_fields": config.default_extract_fields,
1759 "duration_secs": duration_secs,
1760 "max_vus": self.vus,
1761 "auth_header": self.auth,
1762 "custom_headers": custom_headers,
1763 "skip_tls_verify": self.skip_tls_verify,
1764 "stages": stages.iter().map(|s| serde_json::json!({
1766 "duration": s.duration,
1767 "target": s.target,
1768 })).collect::<Vec<_>>(),
1769 "threshold_percentile": self.threshold_percentile,
1770 "threshold_ms": self.threshold_ms,
1771 "max_error_rate": self.max_error_rate,
1772 "headers": headers_json,
1773 "dynamic_imports": required_imports,
1774 "dynamic_globals": required_globals,
1775 "extracted_values_output_path": self
1776 .output
1777 .join("crud_flow_extracted_values.json")
1778 .to_string_lossy(),
1779 "error_injection_enabled": error_injection_enabled,
1781 "error_rate": error_rate,
1782 "error_types": error_types,
1783 "security_testing_enabled": security_testing_enabled,
1785 "has_custom_headers": !custom_headers.is_empty(),
1786 });
1787
1788 let mut script = handlebars
1789 .render_template(template, &data)
1790 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1791
1792 if security_testing_enabled {
1794 script = self.generate_enhanced_script(&script)?;
1795 }
1796
1797 TerminalReporter::print_progress("Validating CRUD flow script...");
1799 let validation_errors = K6ScriptGenerator::validate_script(&script);
1800 if !validation_errors.is_empty() {
1801 TerminalReporter::print_error("CRUD flow script validation failed");
1802 for error in &validation_errors {
1803 eprintln!(" {}", error);
1804 }
1805 return Err(BenchError::Other(format!(
1806 "CRUD flow script validation failed with {} error(s)",
1807 validation_errors.len()
1808 )));
1809 }
1810
1811 TerminalReporter::print_success("CRUD flow script generated");
1812
1813 let script_path = if let Some(output) = &self.script_output {
1815 output.clone()
1816 } else {
1817 self.output.join("k6-crud-flow-script.js")
1818 };
1819
1820 if let Some(parent) = script_path.parent() {
1821 std::fs::create_dir_all(parent)?;
1822 }
1823 std::fs::write(&script_path, &script)?;
1824 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1825
1826 if self.generate_only {
1827 println!("\nScript generated successfully. Run it with:");
1828 println!(" k6 run {}", script_path.display());
1829 return Ok(());
1830 }
1831
1832 TerminalReporter::print_progress("Executing CRUD flow test...");
1834 let executor = K6Executor::new()?;
1835 std::fs::create_dir_all(&self.output)?;
1836
1837 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1838
1839 let duration_secs = Self::parse_duration(&self.duration)?;
1840 TerminalReporter::print_summary(&results, duration_secs);
1841
1842 Ok(())
1843 }
1844
1845 async fn execute_conformance_test(&self) -> Result<()> {
1847 use crate::conformance::generator::{ConformanceConfig, ConformanceGenerator};
1848 use crate::conformance::report::ConformanceReport;
1849 use crate::conformance::spec::ConformanceFeature;
1850
1851 TerminalReporter::print_progress("OpenAPI 3.0.0 Conformance Testing Mode");
1852
1853 TerminalReporter::print_progress(
1856 "Conformance mode runs 1 VU, 1 iteration per endpoint (--vus and -d are ignored)",
1857 );
1858
1859 let categories = self.conformance_categories.as_ref().map(|cats_str| {
1861 cats_str
1862 .split(',')
1863 .filter_map(|s| {
1864 let trimmed = s.trim();
1865 if let Some(canonical) = ConformanceFeature::category_from_cli_name(trimmed) {
1866 Some(canonical.to_string())
1867 } else {
1868 TerminalReporter::print_warning(&format!(
1869 "Unknown conformance category: '{}'. Valid categories: {}",
1870 trimmed,
1871 ConformanceFeature::cli_category_names()
1872 .iter()
1873 .map(|(cli, _)| *cli)
1874 .collect::<Vec<_>>()
1875 .join(", ")
1876 ));
1877 None
1878 }
1879 })
1880 .collect::<Vec<String>>()
1881 });
1882
1883 let custom_headers: Vec<(String, String)> = self
1885 .conformance_headers
1886 .iter()
1887 .filter_map(|h| {
1888 let (name, value) = h.split_once(':')?;
1889 Some((name.trim().to_string(), value.trim().to_string()))
1890 })
1891 .collect();
1892
1893 if !custom_headers.is_empty() {
1894 TerminalReporter::print_progress(&format!(
1895 "Using {} custom header(s) for authentication",
1896 custom_headers.len()
1897 ));
1898 }
1899
1900 if self.conformance_delay_ms > 0 {
1901 TerminalReporter::print_progress(&format!(
1902 "Using {}ms delay between conformance requests",
1903 self.conformance_delay_ms
1904 ));
1905 }
1906
1907 std::fs::create_dir_all(&self.output)?;
1909
1910 let config = ConformanceConfig {
1911 target_url: self.target.clone(),
1912 api_key: self.conformance_api_key.clone(),
1913 basic_auth: self.conformance_basic_auth.clone(),
1914 skip_tls_verify: self.skip_tls_verify,
1915 categories,
1916 base_path: self.base_path.clone(),
1917 custom_headers,
1918 output_dir: Some(self.output.clone()),
1919 all_operations: self.conformance_all_operations,
1920 custom_checks_file: self.conformance_custom.clone(),
1921 request_delay_ms: self.conformance_delay_ms,
1922 };
1923
1924 let annotated_ops = if !self.spec.is_empty() {
1927 TerminalReporter::print_progress("Spec-driven conformance mode: analyzing spec...");
1928 let parser = SpecParser::from_file(&self.spec[0]).await?;
1929 let operations = parser.get_operations();
1930
1931 let annotated =
1932 crate::conformance::spec_driven::SpecDrivenConformanceGenerator::annotate_operations(
1933 &operations,
1934 parser.spec(),
1935 );
1936 TerminalReporter::print_success(&format!(
1937 "Analyzed {} operations, found {} feature annotations",
1938 operations.len(),
1939 annotated.iter().map(|a| a.features.len()).sum::<usize>()
1940 ));
1941 Some(annotated)
1942 } else {
1943 None
1944 };
1945
1946 if self.generate_only || self.use_k6 {
1948 let script = if let Some(annotated) = &annotated_ops {
1949 let gen = crate::conformance::spec_driven::SpecDrivenConformanceGenerator::new(
1950 config,
1951 annotated.clone(),
1952 );
1953 let op_count = gen.operation_count();
1954 let (script, check_count) = gen.generate()?;
1955 TerminalReporter::print_success(&format!(
1956 "Conformance: {} operations analyzed, {} unique checks generated",
1957 op_count, check_count
1958 ));
1959 script
1960 } else {
1961 let generator = ConformanceGenerator::new(config);
1962 generator.generate()?
1963 };
1964
1965 let script_path = self.output.join("k6-conformance.js");
1966 std::fs::write(&script_path, &script).map_err(|e| {
1967 BenchError::Other(format!("Failed to write conformance script: {}", e))
1968 })?;
1969 TerminalReporter::print_success(&format!(
1970 "Conformance script generated: {}",
1971 script_path.display()
1972 ));
1973
1974 if self.generate_only {
1975 println!("\nScript generated. Run with:");
1976 println!(" k6 run {}", script_path.display());
1977 return Ok(());
1978 }
1979
1980 if !K6Executor::is_k6_installed() {
1982 TerminalReporter::print_error("k6 is not installed");
1983 TerminalReporter::print_warning(
1984 "Install k6 from: https://k6.io/docs/get-started/installation/",
1985 );
1986 return Err(BenchError::K6NotFound);
1987 }
1988
1989 TerminalReporter::print_progress("Running conformance tests via k6...");
1990 let executor = K6Executor::new()?;
1991 executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1992
1993 let report_path = self.output.join("conformance-report.json");
1994 if report_path.exists() {
1995 let report = ConformanceReport::from_file(&report_path)?;
1996 report.print_report_with_options(self.conformance_all_operations);
1997 self.save_conformance_report(&report, &report_path)?;
1998 } else {
1999 TerminalReporter::print_warning(
2000 "Conformance report not generated (k6 handleSummary may not have run)",
2001 );
2002 }
2003
2004 return Ok(());
2005 }
2006
2007 TerminalReporter::print_progress("Running conformance tests (native executor)...");
2009
2010 let mut executor = crate::conformance::executor::NativeConformanceExecutor::new(config)?;
2011
2012 executor = if let Some(annotated) = &annotated_ops {
2013 executor.with_spec_driven_checks(annotated)
2014 } else {
2015 executor.with_reference_checks()
2016 };
2017 executor = executor.with_custom_checks()?;
2018
2019 TerminalReporter::print_success(&format!(
2020 "Executing {} conformance checks...",
2021 executor.check_count()
2022 ));
2023
2024 let report = executor.execute().await?;
2025 report.print_report_with_options(self.conformance_all_operations);
2026
2027 let failure_details = report.failure_details();
2029 if !failure_details.is_empty() {
2030 let details_path = self.output.join("conformance-failure-details.json");
2031 if let Ok(json) = serde_json::to_string_pretty(&failure_details) {
2032 let _ = std::fs::write(&details_path, json);
2033 TerminalReporter::print_success(&format!(
2034 "Failure details saved to: {}",
2035 details_path.display()
2036 ));
2037 }
2038 }
2039
2040 let report_path = self.output.join("conformance-report.json");
2042 let report_json = serde_json::to_string_pretty(&report.to_json())
2043 .map_err(|e| BenchError::Other(format!("Failed to serialize report: {}", e)))?;
2044 std::fs::write(&report_path, &report_json)
2045 .map_err(|e| BenchError::Other(format!("Failed to write report: {}", e)))?;
2046 TerminalReporter::print_success(&format!("Report saved to: {}", report_path.display()));
2047
2048 self.save_conformance_report(&report, &report_path)?;
2049
2050 Ok(())
2051 }
2052
2053 fn save_conformance_report(
2055 &self,
2056 report: &crate::conformance::report::ConformanceReport,
2057 report_path: &Path,
2058 ) -> Result<()> {
2059 if self.conformance_report_format == "sarif" {
2060 use crate::conformance::sarif::ConformanceSarifReport;
2061 ConformanceSarifReport::write(report, &self.target, &self.conformance_report)?;
2062 TerminalReporter::print_success(&format!(
2063 "SARIF report saved to: {}",
2064 self.conformance_report.display()
2065 ));
2066 } else if self.conformance_report != *report_path {
2067 std::fs::copy(report_path, &self.conformance_report)?;
2068 TerminalReporter::print_success(&format!(
2069 "Report saved to: {}",
2070 self.conformance_report.display()
2071 ));
2072 }
2073 Ok(())
2074 }
2075
2076 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
2078 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
2079
2080 let custom_headers = self.parse_headers()?;
2082
2083 let mut config = OwaspApiConfig::new()
2085 .with_auth_header(&self.owasp_auth_header)
2086 .with_verbose(self.verbose)
2087 .with_insecure(self.skip_tls_verify)
2088 .with_concurrency(self.vus as usize)
2089 .with_iterations(self.owasp_iterations as usize)
2090 .with_base_path(self.base_path.clone())
2091 .with_custom_headers(custom_headers);
2092
2093 if let Some(ref token) = self.owasp_auth_token {
2095 config = config.with_valid_auth_token(token);
2096 }
2097
2098 if let Some(ref cats_str) = self.owasp_categories {
2100 let categories: Vec<OwaspCategory> = cats_str
2101 .split(',')
2102 .filter_map(|s| {
2103 let trimmed = s.trim();
2104 match trimmed.parse::<OwaspCategory>() {
2105 Ok(cat) => Some(cat),
2106 Err(e) => {
2107 TerminalReporter::print_warning(&e);
2108 None
2109 }
2110 }
2111 })
2112 .collect();
2113
2114 if !categories.is_empty() {
2115 config = config.with_categories(categories);
2116 }
2117 }
2118
2119 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
2121 config.admin_paths_file = Some(admin_paths_file.clone());
2122 if let Err(e) = config.load_admin_paths() {
2123 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
2124 }
2125 }
2126
2127 if let Some(ref id_fields_str) = self.owasp_id_fields {
2129 let id_fields: Vec<String> = id_fields_str
2130 .split(',')
2131 .map(|s| s.trim().to_string())
2132 .filter(|s| !s.is_empty())
2133 .collect();
2134 if !id_fields.is_empty() {
2135 config = config.with_id_fields(id_fields);
2136 }
2137 }
2138
2139 if let Some(ref report_path) = self.owasp_report {
2141 config = config.with_report_path(report_path);
2142 }
2143 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
2144 config = config.with_report_format(format);
2145 }
2146
2147 let categories = config.categories_to_test();
2149 TerminalReporter::print_success(&format!(
2150 "Testing {} OWASP categories: {}",
2151 categories.len(),
2152 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
2153 ));
2154
2155 if config.valid_auth_token.is_some() {
2156 TerminalReporter::print_progress("Using provided auth token for baseline requests");
2157 }
2158
2159 TerminalReporter::print_progress("Generating OWASP security test script...");
2161 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
2162
2163 let script = generator.generate()?;
2165 TerminalReporter::print_success("OWASP security test script generated");
2166
2167 let script_path = if let Some(output) = &self.script_output {
2169 output.clone()
2170 } else {
2171 self.output.join("k6-owasp-security-test.js")
2172 };
2173
2174 if let Some(parent) = script_path.parent() {
2175 std::fs::create_dir_all(parent)?;
2176 }
2177 std::fs::write(&script_path, &script)?;
2178 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
2179
2180 if self.generate_only {
2182 println!("\nOWASP security test script generated. Run it with:");
2183 println!(" k6 run {}", script_path.display());
2184 return Ok(());
2185 }
2186
2187 TerminalReporter::print_progress("Executing OWASP security tests...");
2189 let executor = K6Executor::new()?;
2190 std::fs::create_dir_all(&self.output)?;
2191
2192 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
2193
2194 let duration_secs = Self::parse_duration(&self.duration)?;
2195 TerminalReporter::print_summary(&results, duration_secs);
2196
2197 println!("\nOWASP security test results saved to: {}", self.output.display());
2198
2199 Ok(())
2200 }
2201}
2202
2203#[cfg(test)]
2204mod tests {
2205 use super::*;
2206 use tempfile::tempdir;
2207
2208 #[test]
2209 fn test_parse_duration() {
2210 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
2211 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
2212 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
2213 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
2214 }
2215
2216 #[test]
2217 fn test_parse_duration_invalid() {
2218 assert!(BenchCommand::parse_duration("invalid").is_err());
2219 assert!(BenchCommand::parse_duration("30x").is_err());
2220 }
2221
2222 #[test]
2223 fn test_parse_headers() {
2224 let cmd = BenchCommand {
2225 spec: vec![PathBuf::from("test.yaml")],
2226 spec_dir: None,
2227 merge_conflicts: "error".to_string(),
2228 spec_mode: "merge".to_string(),
2229 dependency_config: None,
2230 target: "http://localhost".to_string(),
2231 base_path: None,
2232 duration: "1m".to_string(),
2233 vus: 10,
2234 scenario: "ramp-up".to_string(),
2235 operations: None,
2236 exclude_operations: None,
2237 auth: None,
2238 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
2239 output: PathBuf::from("output"),
2240 generate_only: false,
2241 script_output: None,
2242 threshold_percentile: "p(95)".to_string(),
2243 threshold_ms: 500,
2244 max_error_rate: 0.05,
2245 verbose: false,
2246 skip_tls_verify: false,
2247 targets_file: None,
2248 max_concurrency: None,
2249 results_format: "both".to_string(),
2250 params_file: None,
2251 crud_flow: false,
2252 flow_config: None,
2253 extract_fields: None,
2254 parallel_create: None,
2255 data_file: None,
2256 data_distribution: "unique-per-vu".to_string(),
2257 data_mappings: None,
2258 per_uri_control: false,
2259 error_rate: None,
2260 error_types: None,
2261 security_test: false,
2262 security_payloads: None,
2263 security_categories: None,
2264 security_target_fields: None,
2265 wafbench_dir: None,
2266 wafbench_cycle_all: false,
2267 owasp_api_top10: false,
2268 owasp_categories: None,
2269 owasp_auth_header: "Authorization".to_string(),
2270 owasp_auth_token: None,
2271 owasp_admin_paths: None,
2272 owasp_id_fields: None,
2273 owasp_report: None,
2274 owasp_report_format: "json".to_string(),
2275 owasp_iterations: 1,
2276 conformance: false,
2277 conformance_api_key: None,
2278 conformance_basic_auth: None,
2279 conformance_report: PathBuf::from("conformance-report.json"),
2280 conformance_categories: None,
2281 conformance_report_format: "json".to_string(),
2282 conformance_headers: vec![],
2283 conformance_all_operations: false,
2284 conformance_custom: None,
2285 conformance_delay_ms: 0,
2286 use_k6: false,
2287 };
2288
2289 let headers = cmd.parse_headers().unwrap();
2290 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
2291 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
2292 }
2293
2294 #[test]
2295 fn test_get_spec_display_name() {
2296 let cmd = BenchCommand {
2297 spec: vec![PathBuf::from("test.yaml")],
2298 spec_dir: None,
2299 merge_conflicts: "error".to_string(),
2300 spec_mode: "merge".to_string(),
2301 dependency_config: None,
2302 target: "http://localhost".to_string(),
2303 base_path: None,
2304 duration: "1m".to_string(),
2305 vus: 10,
2306 scenario: "ramp-up".to_string(),
2307 operations: None,
2308 exclude_operations: None,
2309 auth: None,
2310 headers: None,
2311 output: PathBuf::from("output"),
2312 generate_only: false,
2313 script_output: None,
2314 threshold_percentile: "p(95)".to_string(),
2315 threshold_ms: 500,
2316 max_error_rate: 0.05,
2317 verbose: false,
2318 skip_tls_verify: false,
2319 targets_file: None,
2320 max_concurrency: None,
2321 results_format: "both".to_string(),
2322 params_file: None,
2323 crud_flow: false,
2324 flow_config: None,
2325 extract_fields: None,
2326 parallel_create: None,
2327 data_file: None,
2328 data_distribution: "unique-per-vu".to_string(),
2329 data_mappings: None,
2330 per_uri_control: false,
2331 error_rate: None,
2332 error_types: None,
2333 security_test: false,
2334 security_payloads: None,
2335 security_categories: None,
2336 security_target_fields: None,
2337 wafbench_dir: None,
2338 wafbench_cycle_all: false,
2339 owasp_api_top10: false,
2340 owasp_categories: None,
2341 owasp_auth_header: "Authorization".to_string(),
2342 owasp_auth_token: None,
2343 owasp_admin_paths: None,
2344 owasp_id_fields: None,
2345 owasp_report: None,
2346 owasp_report_format: "json".to_string(),
2347 owasp_iterations: 1,
2348 conformance: false,
2349 conformance_api_key: None,
2350 conformance_basic_auth: None,
2351 conformance_report: PathBuf::from("conformance-report.json"),
2352 conformance_categories: None,
2353 conformance_report_format: "json".to_string(),
2354 conformance_headers: vec![],
2355 conformance_all_operations: false,
2356 conformance_custom: None,
2357 conformance_delay_ms: 0,
2358 use_k6: false,
2359 };
2360
2361 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
2362
2363 let cmd_multi = BenchCommand {
2365 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
2366 spec_dir: None,
2367 merge_conflicts: "error".to_string(),
2368 spec_mode: "merge".to_string(),
2369 dependency_config: None,
2370 target: "http://localhost".to_string(),
2371 base_path: None,
2372 duration: "1m".to_string(),
2373 vus: 10,
2374 scenario: "ramp-up".to_string(),
2375 operations: None,
2376 exclude_operations: None,
2377 auth: None,
2378 headers: None,
2379 output: PathBuf::from("output"),
2380 generate_only: false,
2381 script_output: None,
2382 threshold_percentile: "p(95)".to_string(),
2383 threshold_ms: 500,
2384 max_error_rate: 0.05,
2385 verbose: false,
2386 skip_tls_verify: false,
2387 targets_file: None,
2388 max_concurrency: None,
2389 results_format: "both".to_string(),
2390 params_file: None,
2391 crud_flow: false,
2392 flow_config: None,
2393 extract_fields: None,
2394 parallel_create: None,
2395 data_file: None,
2396 data_distribution: "unique-per-vu".to_string(),
2397 data_mappings: None,
2398 per_uri_control: false,
2399 error_rate: None,
2400 error_types: None,
2401 security_test: false,
2402 security_payloads: None,
2403 security_categories: None,
2404 security_target_fields: None,
2405 wafbench_dir: None,
2406 wafbench_cycle_all: false,
2407 owasp_api_top10: false,
2408 owasp_categories: None,
2409 owasp_auth_header: "Authorization".to_string(),
2410 owasp_auth_token: None,
2411 owasp_admin_paths: None,
2412 owasp_id_fields: None,
2413 owasp_report: None,
2414 owasp_report_format: "json".to_string(),
2415 owasp_iterations: 1,
2416 conformance: false,
2417 conformance_api_key: None,
2418 conformance_basic_auth: None,
2419 conformance_report: PathBuf::from("conformance-report.json"),
2420 conformance_categories: None,
2421 conformance_report_format: "json".to_string(),
2422 conformance_headers: vec![],
2423 conformance_all_operations: false,
2424 conformance_custom: None,
2425 conformance_delay_ms: 0,
2426 use_k6: false,
2427 };
2428
2429 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
2430 }
2431
2432 #[test]
2433 fn test_parse_extracted_values_from_output_dir() {
2434 let dir = tempdir().unwrap();
2435 let path = dir.path().join("extracted_values.json");
2436 std::fs::write(
2437 &path,
2438 r#"{
2439 "pool_id": "abc123",
2440 "count": 0,
2441 "enabled": false,
2442 "metadata": { "owner": "team-a" }
2443}"#,
2444 )
2445 .unwrap();
2446
2447 let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
2448 assert_eq!(extracted.get("pool_id"), Some(&serde_json::json!("abc123")));
2449 assert_eq!(extracted.get("count"), Some(&serde_json::json!(0)));
2450 assert_eq!(extracted.get("enabled"), Some(&serde_json::json!(false)));
2451 assert_eq!(extracted.get("metadata"), Some(&serde_json::json!({"owner": "team-a"})));
2452 }
2453
2454 #[test]
2455 fn test_parse_extracted_values_missing_file() {
2456 let dir = tempdir().unwrap();
2457 let extracted = BenchCommand::parse_extracted_values(dir.path()).unwrap();
2458 assert!(extracted.values.is_empty());
2459 }
2460}