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