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