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, InvalidDataType};
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
125 pub owasp_api_top10: bool,
128 pub owasp_categories: Option<String>,
130 pub owasp_auth_header: String,
132 pub owasp_auth_token: Option<String>,
134 pub owasp_admin_paths: Option<PathBuf>,
136 pub owasp_id_fields: Option<String>,
138 pub owasp_report: Option<PathBuf>,
140 pub owasp_report_format: String,
142}
143
144impl BenchCommand {
145 pub async fn load_and_merge_specs(&self) -> Result<OpenApiSpec> {
147 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
148
149 if !self.spec.is_empty() {
151 let specs = load_specs_from_files(self.spec.clone())
152 .await
153 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
154 all_specs.extend(specs);
155 }
156
157 if let Some(spec_dir) = &self.spec_dir {
159 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
160 BenchError::Other(format!("Failed to load specs from directory: {}", e))
161 })?;
162 all_specs.extend(dir_specs);
163 }
164
165 if all_specs.is_empty() {
166 return Err(BenchError::Other(
167 "No spec files provided. Use --spec or --spec-dir.".to_string(),
168 ));
169 }
170
171 if all_specs.len() == 1 {
173 return Ok(all_specs.into_iter().next().expect("checked len() == 1 above").1);
175 }
176
177 let conflict_strategy = match self.merge_conflicts.as_str() {
179 "first" => ConflictStrategy::First,
180 "last" => ConflictStrategy::Last,
181 _ => ConflictStrategy::Error,
182 };
183
184 merge_specs(all_specs, conflict_strategy)
185 .map_err(|e| BenchError::Other(format!("Failed to merge specs: {}", e)))
186 }
187
188 fn get_spec_display_name(&self) -> String {
190 if self.spec.len() == 1 {
191 self.spec[0].to_string_lossy().to_string()
192 } else if !self.spec.is_empty() {
193 format!("{} spec files", self.spec.len())
194 } else if let Some(dir) = &self.spec_dir {
195 format!("specs from {}", dir.display())
196 } else {
197 "no specs".to_string()
198 }
199 }
200
201 pub async fn execute(&self) -> Result<()> {
203 if let Some(targets_file) = &self.targets_file {
205 return self.execute_multi_target(targets_file).await;
206 }
207
208 if self.spec_mode == "sequential" && (self.spec.len() > 1 || self.spec_dir.is_some()) {
210 return self.execute_sequential_specs().await;
211 }
212
213 TerminalReporter::print_header(
216 &self.get_spec_display_name(),
217 &self.target,
218 0, &self.scenario,
220 Self::parse_duration(&self.duration)?,
221 );
222
223 if !K6Executor::is_k6_installed() {
225 TerminalReporter::print_error("k6 is not installed");
226 TerminalReporter::print_warning(
227 "Install k6 from: https://k6.io/docs/get-started/installation/",
228 );
229 return Err(BenchError::K6NotFound);
230 }
231
232 TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
234 let merged_spec = self.load_and_merge_specs().await?;
235 let parser = SpecParser::from_spec(merged_spec);
236 if self.spec.len() > 1 || self.spec_dir.is_some() {
237 TerminalReporter::print_success(&format!(
238 "Loaded and merged {} specification(s)",
239 self.spec.len() + self.spec_dir.as_ref().map(|_| 1).unwrap_or(0)
240 ));
241 } else {
242 TerminalReporter::print_success("Specification loaded");
243 }
244
245 let mock_config = self.build_mock_config().await;
247 if mock_config.is_mock_server {
248 TerminalReporter::print_progress("Mock server integration enabled");
249 }
250
251 if self.crud_flow {
253 return self.execute_crud_flow(&parser).await;
254 }
255
256 if self.owasp_api_top10 {
258 return self.execute_owasp_test(&parser).await;
259 }
260
261 TerminalReporter::print_progress("Extracting API operations...");
263 let mut operations = if let Some(filter) = &self.operations {
264 parser.filter_operations(filter)?
265 } else {
266 parser.get_operations()
267 };
268
269 if let Some(exclude) = &self.exclude_operations {
271 let before_count = operations.len();
272 operations = parser.exclude_operations(operations, exclude)?;
273 let excluded_count = before_count - operations.len();
274 if excluded_count > 0 {
275 TerminalReporter::print_progress(&format!(
276 "Excluded {} operations matching '{}'",
277 excluded_count, exclude
278 ));
279 }
280 }
281
282 if operations.is_empty() {
283 return Err(BenchError::Other("No operations found in spec".to_string()));
284 }
285
286 TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
287
288 let param_overrides = if let Some(params_file) = &self.params_file {
290 TerminalReporter::print_progress("Loading parameter overrides...");
291 let overrides = ParameterOverrides::from_file(params_file)?;
292 TerminalReporter::print_success(&format!(
293 "Loaded parameter overrides ({} operation-specific, {} defaults)",
294 overrides.operations.len(),
295 if overrides.defaults.is_empty() { 0 } else { 1 }
296 ));
297 Some(overrides)
298 } else {
299 None
300 };
301
302 TerminalReporter::print_progress("Generating request templates...");
304 let templates: Vec<_> = operations
305 .iter()
306 .map(|op| {
307 let op_overrides = param_overrides.as_ref().map(|po| {
308 po.get_for_operation(op.operation_id.as_deref(), &op.method, &op.path)
309 });
310 RequestGenerator::generate_template_with_overrides(op, op_overrides.as_ref())
311 })
312 .collect::<Result<Vec<_>>>()?;
313 TerminalReporter::print_success("Request templates generated");
314
315 let custom_headers = self.parse_headers()?;
317
318 let base_path = self.resolve_base_path(&parser);
320 if let Some(ref bp) = base_path {
321 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
322 }
323
324 TerminalReporter::print_progress("Generating k6 load test script...");
326 let scenario =
327 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
328
329 let k6_config = K6Config {
330 target_url: self.target.clone(),
331 base_path,
332 scenario,
333 duration_secs: Self::parse_duration(&self.duration)?,
334 max_vus: self.vus,
335 threshold_percentile: self.threshold_percentile.clone(),
336 threshold_ms: self.threshold_ms,
337 max_error_rate: self.max_error_rate,
338 auth_header: self.auth.clone(),
339 custom_headers,
340 skip_tls_verify: self.skip_tls_verify,
341 };
342
343 let generator = K6ScriptGenerator::new(k6_config, templates);
344 let mut script = generator.generate()?;
345 TerminalReporter::print_success("k6 script generated");
346
347 let has_advanced_features = self.data_file.is_some()
349 || self.error_rate.is_some()
350 || self.security_test
351 || self.parallel_create.is_some();
352
353 if has_advanced_features {
355 script = self.generate_enhanced_script(&script)?;
356 }
357
358 if mock_config.is_mock_server {
360 let setup_code = MockIntegrationGenerator::generate_setup(&mock_config);
361 let teardown_code = MockIntegrationGenerator::generate_teardown(&mock_config);
362 let helper_code = MockIntegrationGenerator::generate_vu_id_helper();
363
364 if let Some(import_end) = script.find("export const options") {
366 script.insert_str(
367 import_end,
368 &format!(
369 "\n// === Mock Server Integration ===\n{}\n{}\n{}\n",
370 helper_code, setup_code, teardown_code
371 ),
372 );
373 }
374 }
375
376 TerminalReporter::print_progress("Validating k6 script...");
378 let validation_errors = K6ScriptGenerator::validate_script(&script);
379 if !validation_errors.is_empty() {
380 TerminalReporter::print_error("Script validation failed");
381 for error in &validation_errors {
382 eprintln!(" {}", error);
383 }
384 return Err(BenchError::Other(format!(
385 "Generated k6 script has {} validation error(s). Please check the output above.",
386 validation_errors.len()
387 )));
388 }
389 TerminalReporter::print_success("Script validation passed");
390
391 let script_path = if let Some(output) = &self.script_output {
393 output.clone()
394 } else {
395 self.output.join("k6-script.js")
396 };
397
398 if let Some(parent) = script_path.parent() {
399 std::fs::create_dir_all(parent)?;
400 }
401 std::fs::write(&script_path, &script)?;
402 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
403
404 if self.generate_only {
406 println!("\nScript generated successfully. Run it with:");
407 println!(" k6 run {}", script_path.display());
408 return Ok(());
409 }
410
411 TerminalReporter::print_progress("Executing load test...");
413 let executor = K6Executor::new()?;
414
415 std::fs::create_dir_all(&self.output)?;
416
417 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
418
419 let duration_secs = Self::parse_duration(&self.duration)?;
421 TerminalReporter::print_summary(&results, duration_secs);
422
423 println!("\nResults saved to: {}", self.output.display());
424
425 Ok(())
426 }
427
428 async fn execute_multi_target(&self, targets_file: &PathBuf) -> Result<()> {
430 TerminalReporter::print_progress("Parsing targets file...");
431 let targets = parse_targets_file(targets_file)?;
432 let num_targets = targets.len();
433 TerminalReporter::print_success(&format!("Loaded {} targets", num_targets));
434
435 if targets.is_empty() {
436 return Err(BenchError::Other("No targets found in file".to_string()));
437 }
438
439 let max_concurrency = self.max_concurrency.unwrap_or(10) as usize;
441 let max_concurrency = max_concurrency.min(num_targets); TerminalReporter::print_header(
445 &self.get_spec_display_name(),
446 &format!("{} targets", num_targets),
447 0,
448 &self.scenario,
449 Self::parse_duration(&self.duration)?,
450 );
451
452 let executor = ParallelExecutor::new(
454 BenchCommand {
455 spec: self.spec.clone(),
457 spec_dir: self.spec_dir.clone(),
458 merge_conflicts: self.merge_conflicts.clone(),
459 spec_mode: self.spec_mode.clone(),
460 dependency_config: self.dependency_config.clone(),
461 target: self.target.clone(), base_path: self.base_path.clone(),
463 duration: self.duration.clone(),
464 vus: self.vus,
465 scenario: self.scenario.clone(),
466 operations: self.operations.clone(),
467 exclude_operations: self.exclude_operations.clone(),
468 auth: self.auth.clone(),
469 headers: self.headers.clone(),
470 output: self.output.clone(),
471 generate_only: self.generate_only,
472 script_output: self.script_output.clone(),
473 threshold_percentile: self.threshold_percentile.clone(),
474 threshold_ms: self.threshold_ms,
475 max_error_rate: self.max_error_rate,
476 verbose: self.verbose,
477 skip_tls_verify: self.skip_tls_verify,
478 targets_file: None,
479 max_concurrency: None,
480 results_format: self.results_format.clone(),
481 params_file: self.params_file.clone(),
482 crud_flow: self.crud_flow,
483 flow_config: self.flow_config.clone(),
484 extract_fields: self.extract_fields.clone(),
485 parallel_create: self.parallel_create,
486 data_file: self.data_file.clone(),
487 data_distribution: self.data_distribution.clone(),
488 data_mappings: self.data_mappings.clone(),
489 per_uri_control: self.per_uri_control,
490 error_rate: self.error_rate,
491 error_types: self.error_types.clone(),
492 security_test: self.security_test,
493 security_payloads: self.security_payloads.clone(),
494 security_categories: self.security_categories.clone(),
495 security_target_fields: self.security_target_fields.clone(),
496 wafbench_dir: self.wafbench_dir.clone(),
497 owasp_api_top10: self.owasp_api_top10,
498 owasp_categories: self.owasp_categories.clone(),
499 owasp_auth_header: self.owasp_auth_header.clone(),
500 owasp_auth_token: self.owasp_auth_token.clone(),
501 owasp_admin_paths: self.owasp_admin_paths.clone(),
502 owasp_id_fields: self.owasp_id_fields.clone(),
503 owasp_report: self.owasp_report.clone(),
504 owasp_report_format: self.owasp_report_format.clone(),
505 },
506 targets,
507 max_concurrency,
508 );
509
510 let aggregated_results = executor.execute_all().await?;
512
513 self.report_multi_target_results(&aggregated_results)?;
515
516 Ok(())
517 }
518
519 fn report_multi_target_results(&self, results: &AggregatedResults) -> Result<()> {
521 TerminalReporter::print_multi_target_summary(results);
523
524 if self.results_format == "aggregated" || self.results_format == "both" {
526 let summary_path = self.output.join("aggregated_summary.json");
527 let summary_json = serde_json::json!({
528 "total_targets": results.total_targets,
529 "successful_targets": results.successful_targets,
530 "failed_targets": results.failed_targets,
531 "aggregated_metrics": {
532 "total_requests": results.aggregated_metrics.total_requests,
533 "total_failed_requests": results.aggregated_metrics.total_failed_requests,
534 "avg_duration_ms": results.aggregated_metrics.avg_duration_ms,
535 "p95_duration_ms": results.aggregated_metrics.p95_duration_ms,
536 "p99_duration_ms": results.aggregated_metrics.p99_duration_ms,
537 "error_rate": results.aggregated_metrics.error_rate,
538 },
539 "target_results": results.target_results.iter().map(|r| {
540 serde_json::json!({
541 "target_url": r.target_url,
542 "target_index": r.target_index,
543 "success": r.success,
544 "error": r.error,
545 "total_requests": r.results.total_requests,
546 "failed_requests": r.results.failed_requests,
547 "avg_duration_ms": r.results.avg_duration_ms,
548 "p95_duration_ms": r.results.p95_duration_ms,
549 "p99_duration_ms": r.results.p99_duration_ms,
550 "output_dir": r.output_dir.to_string_lossy(),
551 })
552 }).collect::<Vec<_>>(),
553 });
554
555 std::fs::write(&summary_path, serde_json::to_string_pretty(&summary_json)?)?;
556 TerminalReporter::print_success(&format!(
557 "Aggregated summary saved to: {}",
558 summary_path.display()
559 ));
560 }
561
562 println!("\nResults saved to: {}", self.output.display());
563 println!(" - Per-target results: {}", self.output.join("target_*").display());
564 if self.results_format == "aggregated" || self.results_format == "both" {
565 println!(
566 " - Aggregated summary: {}",
567 self.output.join("aggregated_summary.json").display()
568 );
569 }
570
571 Ok(())
572 }
573
574 pub fn parse_duration(duration: &str) -> Result<u64> {
576 let duration = duration.trim();
577
578 if let Some(secs) = duration.strip_suffix('s') {
579 secs.parse::<u64>()
580 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
581 } else if let Some(mins) = duration.strip_suffix('m') {
582 mins.parse::<u64>()
583 .map(|m| m * 60)
584 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
585 } else if let Some(hours) = duration.strip_suffix('h') {
586 hours
587 .parse::<u64>()
588 .map(|h| h * 3600)
589 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
590 } else {
591 duration
593 .parse::<u64>()
594 .map_err(|_| BenchError::Other(format!("Invalid duration: {}", duration)))
595 }
596 }
597
598 pub fn parse_headers(&self) -> Result<HashMap<String, String>> {
600 let mut headers = HashMap::new();
601
602 if let Some(header_str) = &self.headers {
603 for pair in header_str.split(',') {
604 let parts: Vec<&str> = pair.splitn(2, ':').collect();
605 if parts.len() != 2 {
606 return Err(BenchError::Other(format!(
607 "Invalid header format: '{}'. Expected 'Key:Value'",
608 pair
609 )));
610 }
611 headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
612 }
613 }
614
615 Ok(headers)
616 }
617
618 fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
627 if let Some(cli_base_path) = &self.base_path {
629 if cli_base_path.is_empty() {
630 return None;
632 }
633 return Some(cli_base_path.clone());
634 }
635
636 parser.get_base_path()
638 }
639
640 async fn build_mock_config(&self) -> MockIntegrationConfig {
642 if MockServerDetector::looks_like_mock_server(&self.target) {
644 if let Ok(info) = MockServerDetector::detect(&self.target).await {
646 if info.is_mockforge {
647 TerminalReporter::print_success(&format!(
648 "Detected MockForge server (version: {})",
649 info.version.as_deref().unwrap_or("unknown")
650 ));
651 return MockIntegrationConfig::mock_server();
652 }
653 }
654 }
655 MockIntegrationConfig::real_api()
656 }
657
658 fn build_crud_flow_config(&self) -> Option<CrudFlowConfig> {
660 if !self.crud_flow {
661 return None;
662 }
663
664 if let Some(config_path) = &self.flow_config {
666 match CrudFlowConfig::from_file(config_path) {
667 Ok(config) => return Some(config),
668 Err(e) => {
669 TerminalReporter::print_warning(&format!(
670 "Failed to load flow config: {}. Using auto-detection.",
671 e
672 ));
673 }
674 }
675 }
676
677 let extract_fields = self
679 .extract_fields
680 .as_ref()
681 .map(|f| f.split(',').map(|s| s.trim().to_string()).collect())
682 .unwrap_or_else(|| vec!["id".to_string(), "uuid".to_string()]);
683
684 Some(CrudFlowConfig {
685 flows: Vec::new(), default_extract_fields: extract_fields,
687 })
688 }
689
690 fn build_data_driven_config(&self) -> Option<DataDrivenConfig> {
692 let data_file = self.data_file.as_ref()?;
693
694 let distribution = DataDistribution::from_str(&self.data_distribution)
695 .unwrap_or(DataDistribution::UniquePerVu);
696
697 let mappings = self
698 .data_mappings
699 .as_ref()
700 .map(|m| DataMapping::parse_mappings(m).unwrap_or_default())
701 .unwrap_or_default();
702
703 Some(DataDrivenConfig {
704 file_path: data_file.to_string_lossy().to_string(),
705 distribution,
706 mappings,
707 csv_has_header: true,
708 per_uri_control: self.per_uri_control,
709 per_uri_columns: crate::data_driven::PerUriColumns::default(),
710 })
711 }
712
713 fn build_invalid_data_config(&self) -> Option<InvalidDataConfig> {
715 let error_rate = self.error_rate?;
716
717 let error_types = self
718 .error_types
719 .as_ref()
720 .map(|types| InvalidDataConfig::parse_error_types(types).unwrap_or_default())
721 .unwrap_or_default();
722
723 Some(InvalidDataConfig {
724 error_rate,
725 error_types,
726 target_fields: Vec::new(),
727 })
728 }
729
730 fn build_security_config(&self) -> Option<SecurityTestConfig> {
732 if !self.security_test {
733 return None;
734 }
735
736 let categories = self
737 .security_categories
738 .as_ref()
739 .map(|cats| SecurityTestConfig::parse_categories(cats).unwrap_or_default())
740 .unwrap_or_else(|| {
741 let mut default = std::collections::HashSet::new();
742 default.insert(SecurityCategory::SqlInjection);
743 default.insert(SecurityCategory::Xss);
744 default
745 });
746
747 let target_fields = self
748 .security_target_fields
749 .as_ref()
750 .map(|fields| fields.split(',').map(|f| f.trim().to_string()).collect())
751 .unwrap_or_default();
752
753 let custom_payloads_file =
754 self.security_payloads.as_ref().map(|p| p.to_string_lossy().to_string());
755
756 Some(SecurityTestConfig {
757 enabled: true,
758 categories,
759 target_fields,
760 custom_payloads_file,
761 include_high_risk: false,
762 })
763 }
764
765 fn build_parallel_config(&self) -> Option<ParallelConfig> {
767 let count = self.parallel_create?;
768
769 Some(ParallelConfig::new(count))
770 }
771
772 fn load_wafbench_payloads(&self) -> Vec<SecurityPayload> {
774 let Some(ref wafbench_dir) = self.wafbench_dir else {
775 return Vec::new();
776 };
777
778 let mut loader = WafBenchLoader::new();
779
780 if let Err(e) = loader.load_from_pattern(wafbench_dir) {
781 TerminalReporter::print_warning(&format!("Failed to load WAFBench tests: {}", e));
782 return Vec::new();
783 }
784
785 let stats = loader.stats();
786
787 if stats.files_processed == 0 {
788 TerminalReporter::print_warning(&format!(
789 "No WAFBench YAML files found matching '{}'",
790 wafbench_dir
791 ));
792 if !stats.parse_errors.is_empty() {
794 TerminalReporter::print_warning("Some files were found but failed to parse:");
795 for error in &stats.parse_errors {
796 TerminalReporter::print_warning(&format!(" - {}", error));
797 }
798 }
799 return Vec::new();
800 }
801
802 TerminalReporter::print_progress(&format!(
803 "Loaded {} WAFBench files, {} test cases, {} payloads",
804 stats.files_processed, stats.test_cases_loaded, stats.payloads_extracted
805 ));
806
807 for (category, count) in &stats.by_category {
809 TerminalReporter::print_progress(&format!(" - {}: {} tests", category, count));
810 }
811
812 for error in &stats.parse_errors {
814 TerminalReporter::print_warning(&format!(" Parse error: {}", error));
815 }
816
817 loader.to_security_payloads()
818 }
819
820 fn generate_enhanced_script(&self, base_script: &str) -> Result<String> {
822 let mut enhanced_script = base_script.to_string();
823 let mut additional_code = String::new();
824
825 if let Some(config) = self.build_data_driven_config() {
827 TerminalReporter::print_progress("Adding data-driven testing support...");
828 additional_code.push_str(&DataDrivenGenerator::generate_setup(&config));
829 additional_code.push('\n');
830 TerminalReporter::print_success("Data-driven testing enabled");
831 }
832
833 if let Some(config) = self.build_invalid_data_config() {
835 TerminalReporter::print_progress("Adding invalid data testing support...");
836 additional_code.push_str(&InvalidDataGenerator::generate_invalidation_logic());
837 additional_code.push('\n');
838 additional_code
839 .push_str(&InvalidDataGenerator::generate_should_invalidate(config.error_rate));
840 additional_code.push('\n');
841 additional_code
842 .push_str(&InvalidDataGenerator::generate_type_selection(&config.error_types));
843 additional_code.push('\n');
844 TerminalReporter::print_success(&format!(
845 "Invalid data testing enabled ({}% error rate)",
846 (self.error_rate.unwrap_or(0.0) * 100.0) as u32
847 ));
848 }
849
850 let security_config = self.build_security_config();
852 let wafbench_payloads = self.load_wafbench_payloads();
853
854 if security_config.is_some() || !wafbench_payloads.is_empty() {
855 TerminalReporter::print_progress("Adding security testing support...");
856
857 let mut payload_list: Vec<SecurityPayload> = Vec::new();
859
860 if let Some(ref config) = security_config {
861 payload_list.extend(SecurityPayloads::get_payloads(config));
862 }
863
864 if !wafbench_payloads.is_empty() {
866 TerminalReporter::print_progress(&format!(
867 "Loading {} WAFBench attack patterns...",
868 wafbench_payloads.len()
869 ));
870 payload_list.extend(wafbench_payloads);
871 }
872
873 let target_fields =
874 security_config.as_ref().map(|c| c.target_fields.clone()).unwrap_or_default();
875
876 additional_code
877 .push_str(&SecurityTestGenerator::generate_payload_selection(&payload_list));
878 additional_code.push('\n');
879 additional_code
880 .push_str(&SecurityTestGenerator::generate_apply_payload(&target_fields));
881 additional_code.push('\n');
882 additional_code.push_str(&SecurityTestGenerator::generate_security_checks());
883 additional_code.push('\n');
884 TerminalReporter::print_success(&format!(
885 "Security testing enabled ({} payloads)",
886 payload_list.len()
887 ));
888 }
889
890 if let Some(config) = self.build_parallel_config() {
892 TerminalReporter::print_progress("Adding parallel execution support...");
893 additional_code.push_str(&ParallelRequestGenerator::generate_batch_helper(&config));
894 additional_code.push('\n');
895 TerminalReporter::print_success(&format!(
896 "Parallel execution enabled (count: {})",
897 config.count
898 ));
899 }
900
901 if !additional_code.is_empty() {
903 if let Some(import_end) = enhanced_script.find("export const options") {
905 enhanced_script.insert_str(
906 import_end,
907 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
908 );
909 }
910 }
911
912 Ok(enhanced_script)
913 }
914
915 async fn execute_sequential_specs(&self) -> Result<()> {
917 TerminalReporter::print_progress("Sequential spec mode: Loading specs individually...");
918
919 let mut all_specs: Vec<(PathBuf, OpenApiSpec)> = Vec::new();
921
922 if !self.spec.is_empty() {
923 let specs = load_specs_from_files(self.spec.clone())
924 .await
925 .map_err(|e| BenchError::Other(format!("Failed to load spec files: {}", e)))?;
926 all_specs.extend(specs);
927 }
928
929 if let Some(spec_dir) = &self.spec_dir {
930 let dir_specs = load_specs_from_directory(spec_dir).await.map_err(|e| {
931 BenchError::Other(format!("Failed to load specs from directory: {}", e))
932 })?;
933 all_specs.extend(dir_specs);
934 }
935
936 if all_specs.is_empty() {
937 return Err(BenchError::Other(
938 "No spec files found for sequential execution".to_string(),
939 ));
940 }
941
942 TerminalReporter::print_success(&format!("Loaded {} spec(s)", all_specs.len()));
943
944 let execution_order = if let Some(config_path) = &self.dependency_config {
946 TerminalReporter::print_progress("Loading dependency configuration...");
947 let config = SpecDependencyConfig::from_file(config_path)?;
948
949 if !config.disable_auto_detect && config.execution_order.is_empty() {
950 self.detect_and_sort_specs(&all_specs)?
952 } else {
953 config.execution_order.iter().flat_map(|g| g.specs.clone()).collect()
955 }
956 } else {
957 self.detect_and_sort_specs(&all_specs)?
959 };
960
961 TerminalReporter::print_success(&format!(
962 "Execution order: {}",
963 execution_order
964 .iter()
965 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
966 .collect::<Vec<_>>()
967 .join(" → ")
968 ));
969
970 let mut extracted_values = ExtractedValues::new();
972 let total_specs = execution_order.len();
973
974 for (index, spec_path) in execution_order.iter().enumerate() {
975 let spec_name = spec_path.file_name().unwrap_or_default().to_string_lossy().to_string();
976
977 TerminalReporter::print_progress(&format!(
978 "[{}/{}] Executing spec: {}",
979 index + 1,
980 total_specs,
981 spec_name
982 ));
983
984 let spec = all_specs
986 .iter()
987 .find(|(p, _)| {
988 p == spec_path
989 || p.file_name() == spec_path.file_name()
990 || p.file_name() == Some(spec_path.as_os_str())
991 })
992 .map(|(_, s)| s.clone())
993 .ok_or_else(|| {
994 BenchError::Other(format!("Spec not found: {}", spec_path.display()))
995 })?;
996
997 let new_values = self.execute_single_spec(&spec, &spec_name, &extracted_values).await?;
999
1000 extracted_values.merge(&new_values);
1002
1003 TerminalReporter::print_success(&format!(
1004 "[{}/{}] Completed: {} (extracted {} values)",
1005 index + 1,
1006 total_specs,
1007 spec_name,
1008 new_values.values.len()
1009 ));
1010 }
1011
1012 TerminalReporter::print_success(&format!(
1013 "Sequential execution complete: {} specs executed",
1014 total_specs
1015 ));
1016
1017 Ok(())
1018 }
1019
1020 fn detect_and_sort_specs(&self, specs: &[(PathBuf, OpenApiSpec)]) -> Result<Vec<PathBuf>> {
1022 TerminalReporter::print_progress("Auto-detecting spec dependencies...");
1023
1024 let mut detector = DependencyDetector::new();
1025 let dependencies = detector.detect_dependencies(specs);
1026
1027 if dependencies.is_empty() {
1028 TerminalReporter::print_progress("No dependencies detected, using file order");
1029 return Ok(specs.iter().map(|(p, _)| p.clone()).collect());
1030 }
1031
1032 TerminalReporter::print_progress(&format!(
1033 "Detected {} cross-spec dependencies",
1034 dependencies.len()
1035 ));
1036
1037 for dep in &dependencies {
1038 TerminalReporter::print_progress(&format!(
1039 " {} → {} (via field '{}')",
1040 dep.dependency_spec.file_name().unwrap_or_default().to_string_lossy(),
1041 dep.dependent_spec.file_name().unwrap_or_default().to_string_lossy(),
1042 dep.field_name
1043 ));
1044 }
1045
1046 topological_sort(specs, &dependencies)
1047 }
1048
1049 async fn execute_single_spec(
1051 &self,
1052 spec: &OpenApiSpec,
1053 spec_name: &str,
1054 _external_values: &ExtractedValues,
1055 ) -> Result<ExtractedValues> {
1056 let parser = SpecParser::from_spec(spec.clone());
1057
1058 if self.crud_flow {
1060 self.execute_crud_flow_with_extraction(&parser, spec_name).await
1062 } else {
1063 self.execute_standard_spec(&parser, spec_name).await?;
1065 Ok(ExtractedValues::new())
1066 }
1067 }
1068
1069 async fn execute_crud_flow_with_extraction(
1071 &self,
1072 parser: &SpecParser,
1073 spec_name: &str,
1074 ) -> Result<ExtractedValues> {
1075 let operations = parser.get_operations();
1076 let flows = CrudFlowDetector::detect_flows(&operations);
1077
1078 if flows.is_empty() {
1079 TerminalReporter::print_warning(&format!("No CRUD flows detected in {}", spec_name));
1080 return Ok(ExtractedValues::new());
1081 }
1082
1083 TerminalReporter::print_progress(&format!(
1084 " {} CRUD flow(s) in {}",
1085 flows.len(),
1086 spec_name
1087 ));
1088
1089 let mut handlebars = handlebars::Handlebars::new();
1091 handlebars.register_helper(
1093 "json",
1094 Box::new(
1095 |h: &handlebars::Helper,
1096 _: &handlebars::Handlebars,
1097 _: &handlebars::Context,
1098 _: &mut handlebars::RenderContext,
1099 out: &mut dyn handlebars::Output|
1100 -> handlebars::HelperResult {
1101 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1102 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1103 Ok(())
1104 },
1105 ),
1106 );
1107 let template = include_str!("templates/k6_crud_flow.hbs");
1108
1109 let custom_headers = self.parse_headers()?;
1110 let config = self.build_crud_flow_config().unwrap_or_default();
1111
1112 let param_overrides = if let Some(params_file) = &self.params_file {
1114 let overrides = ParameterOverrides::from_file(params_file)?;
1115 Some(overrides)
1116 } else {
1117 None
1118 };
1119
1120 let duration_secs = Self::parse_duration(&self.duration)?;
1122 let scenario =
1123 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1124 let stages = scenario.generate_stages(duration_secs, self.vus);
1125
1126 let api_base_path = self.resolve_base_path(parser);
1128
1129 let mut all_headers = custom_headers.clone();
1131 if let Some(auth) = &self.auth {
1132 all_headers.insert("Authorization".to_string(), auth.clone());
1133 }
1134 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1135
1136 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1138
1139 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1140 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1141 serde_json::json!({
1142 "name": sanitized_name.clone(),
1143 "display_name": f.name,
1144 "base_path": f.base_path,
1145 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1146 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1148 let method_raw = if !parts.is_empty() {
1149 parts[0].to_uppercase()
1150 } else {
1151 "GET".to_string()
1152 };
1153 let method = if !parts.is_empty() {
1154 let m = parts[0].to_lowercase();
1155 if m == "delete" { "del".to_string() } else { m }
1157 } else {
1158 "get".to_string()
1159 };
1160 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1161 let path = if let Some(ref bp) = api_base_path {
1163 format!("{}{}", bp, raw_path)
1164 } else {
1165 raw_path.to_string()
1166 };
1167 let is_get_or_head = method == "get" || method == "head";
1168 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1170
1171 let body_value = if has_body {
1173 param_overrides.as_ref()
1174 .map(|po| po.get_for_operation(None, &method_raw, &raw_path))
1175 .and_then(|oo| oo.body)
1176 .unwrap_or_else(|| serde_json::json!({}))
1177 } else {
1178 serde_json::json!({})
1179 };
1180
1181 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1183
1184 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1186 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1187
1188 serde_json::json!({
1189 "operation": s.operation,
1190 "method": method,
1191 "path": path,
1192 "extract": s.extract,
1193 "use_values": s.use_values,
1194 "use_body": s.use_body,
1195 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1196 "inject_attacks": s.inject_attacks,
1197 "attack_types": s.attack_types,
1198 "description": s.description,
1199 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1200 "is_get_or_head": is_get_or_head,
1201 "has_body": has_body,
1202 "body": processed_body.value,
1203 "body_is_dynamic": body_is_dynamic,
1204 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1205 })
1206 }).collect::<Vec<_>>(),
1207 })
1208 }).collect();
1209
1210 for flow_data in &flows_data {
1212 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1213 for step in steps {
1214 if let Some(placeholders_arr) =
1215 step.get("_placeholders").and_then(|p| p.as_array())
1216 {
1217 for p_str in placeholders_arr {
1218 if let Some(p_name) = p_str.as_str() {
1219 match p_name {
1220 "VU" => {
1221 all_placeholders.insert(DynamicPlaceholder::VU);
1222 }
1223 "Iteration" => {
1224 all_placeholders.insert(DynamicPlaceholder::Iteration);
1225 }
1226 "Timestamp" => {
1227 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1228 }
1229 "UUID" => {
1230 all_placeholders.insert(DynamicPlaceholder::UUID);
1231 }
1232 "Random" => {
1233 all_placeholders.insert(DynamicPlaceholder::Random);
1234 }
1235 "Counter" => {
1236 all_placeholders.insert(DynamicPlaceholder::Counter);
1237 }
1238 "Date" => {
1239 all_placeholders.insert(DynamicPlaceholder::Date);
1240 }
1241 "VuIter" => {
1242 all_placeholders.insert(DynamicPlaceholder::VuIter);
1243 }
1244 _ => {}
1245 }
1246 }
1247 }
1248 }
1249 }
1250 }
1251 }
1252
1253 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1255 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1256
1257 let data = serde_json::json!({
1258 "base_url": self.target,
1259 "flows": flows_data,
1260 "extract_fields": config.default_extract_fields,
1261 "duration_secs": duration_secs,
1262 "max_vus": self.vus,
1263 "auth_header": self.auth,
1264 "custom_headers": custom_headers,
1265 "skip_tls_verify": self.skip_tls_verify,
1266 "stages": stages.iter().map(|s| serde_json::json!({
1268 "duration": s.duration,
1269 "target": s.target,
1270 })).collect::<Vec<_>>(),
1271 "threshold_percentile": self.threshold_percentile,
1272 "threshold_ms": self.threshold_ms,
1273 "max_error_rate": self.max_error_rate,
1274 "headers": headers_json,
1275 "dynamic_imports": required_imports,
1276 "dynamic_globals": required_globals,
1277 });
1278
1279 let script = handlebars
1280 .render_template(template, &data)
1281 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1282
1283 let script_path =
1285 self.output.join(format!("k6-{}-crud-flow.js", spec_name.replace('.', "_")));
1286
1287 std::fs::create_dir_all(self.output.clone())?;
1288 std::fs::write(&script_path, &script)?;
1289
1290 if !self.generate_only {
1291 let executor = K6Executor::new()?;
1292 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1293 std::fs::create_dir_all(&output_dir)?;
1294
1295 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1296 }
1297
1298 Ok(ExtractedValues::new())
1301 }
1302
1303 async fn execute_standard_spec(&self, parser: &SpecParser, spec_name: &str) -> Result<()> {
1305 let mut operations = if let Some(filter) = &self.operations {
1306 parser.filter_operations(filter)?
1307 } else {
1308 parser.get_operations()
1309 };
1310
1311 if let Some(exclude) = &self.exclude_operations {
1312 operations = parser.exclude_operations(operations, exclude)?;
1313 }
1314
1315 if operations.is_empty() {
1316 TerminalReporter::print_warning(&format!("No operations found in {}", spec_name));
1317 return Ok(());
1318 }
1319
1320 TerminalReporter::print_progress(&format!(
1321 " {} operations in {}",
1322 operations.len(),
1323 spec_name
1324 ));
1325
1326 let templates: Vec<_> = operations
1328 .iter()
1329 .map(RequestGenerator::generate_template)
1330 .collect::<Result<Vec<_>>>()?;
1331
1332 let custom_headers = self.parse_headers()?;
1334
1335 let base_path = self.resolve_base_path(parser);
1337
1338 let scenario =
1340 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1341
1342 let k6_config = K6Config {
1343 target_url: self.target.clone(),
1344 base_path,
1345 scenario,
1346 duration_secs: Self::parse_duration(&self.duration)?,
1347 max_vus: self.vus,
1348 threshold_percentile: self.threshold_percentile.clone(),
1349 threshold_ms: self.threshold_ms,
1350 max_error_rate: self.max_error_rate,
1351 auth_header: self.auth.clone(),
1352 custom_headers,
1353 skip_tls_verify: self.skip_tls_verify,
1354 };
1355
1356 let generator = K6ScriptGenerator::new(k6_config, templates);
1357 let script = generator.generate()?;
1358
1359 let script_path = self.output.join(format!("k6-{}.js", spec_name.replace('.', "_")));
1361
1362 std::fs::create_dir_all(self.output.clone())?;
1363 std::fs::write(&script_path, &script)?;
1364
1365 if !self.generate_only {
1366 let executor = K6Executor::new()?;
1367 let output_dir = self.output.join(format!("{}_results", spec_name.replace('.', "_")));
1368 std::fs::create_dir_all(&output_dir)?;
1369
1370 executor.execute(&script_path, Some(&output_dir), self.verbose).await?;
1371 }
1372
1373 Ok(())
1374 }
1375
1376 async fn execute_crud_flow(&self, parser: &SpecParser) -> Result<()> {
1378 let config = self.build_crud_flow_config().unwrap_or_default();
1380
1381 let flows = if !config.flows.is_empty() {
1383 TerminalReporter::print_progress("Using custom flow configuration...");
1384 config.flows.clone()
1385 } else {
1386 TerminalReporter::print_progress("Detecting CRUD operations...");
1387 let operations = parser.get_operations();
1388 CrudFlowDetector::detect_flows(&operations)
1389 };
1390
1391 if flows.is_empty() {
1392 return Err(BenchError::Other(
1393 "No CRUD flows detected in spec. Ensure spec has POST/GET/PUT/DELETE operations on related paths.".to_string(),
1394 ));
1395 }
1396
1397 if config.flows.is_empty() {
1398 TerminalReporter::print_success(&format!("Detected {} CRUD flow(s)", flows.len()));
1399 } else {
1400 TerminalReporter::print_success(&format!("Loaded {} custom flow(s)", flows.len()));
1401 }
1402
1403 for flow in &flows {
1404 TerminalReporter::print_progress(&format!(
1405 " - {}: {} steps",
1406 flow.name,
1407 flow.steps.len()
1408 ));
1409 }
1410
1411 let mut handlebars = handlebars::Handlebars::new();
1413 handlebars.register_helper(
1415 "json",
1416 Box::new(
1417 |h: &handlebars::Helper,
1418 _: &handlebars::Handlebars,
1419 _: &handlebars::Context,
1420 _: &mut handlebars::RenderContext,
1421 out: &mut dyn handlebars::Output|
1422 -> handlebars::HelperResult {
1423 let param = h.param(0).map(|v| v.value()).unwrap_or(&serde_json::Value::Null);
1424 out.write(&serde_json::to_string(param).unwrap_or_else(|_| "[]".to_string()))?;
1425 Ok(())
1426 },
1427 ),
1428 );
1429 let template = include_str!("templates/k6_crud_flow.hbs");
1430
1431 let custom_headers = self.parse_headers()?;
1432
1433 let param_overrides = if let Some(params_file) = &self.params_file {
1435 TerminalReporter::print_progress("Loading parameter overrides...");
1436 let overrides = ParameterOverrides::from_file(params_file)?;
1437 TerminalReporter::print_success(&format!(
1438 "Loaded parameter overrides ({} operation-specific, {} defaults)",
1439 overrides.operations.len(),
1440 if overrides.defaults.is_empty() { 0 } else { 1 }
1441 ));
1442 Some(overrides)
1443 } else {
1444 None
1445 };
1446
1447 let duration_secs = Self::parse_duration(&self.duration)?;
1449 let scenario =
1450 LoadScenario::from_str(&self.scenario).map_err(BenchError::InvalidScenario)?;
1451 let stages = scenario.generate_stages(duration_secs, self.vus);
1452
1453 let api_base_path = self.resolve_base_path(parser);
1455 if let Some(ref bp) = api_base_path {
1456 TerminalReporter::print_progress(&format!("Using base path: {}", bp));
1457 }
1458
1459 let mut all_headers = custom_headers.clone();
1461 if let Some(auth) = &self.auth {
1462 all_headers.insert("Authorization".to_string(), auth.clone());
1463 }
1464 let headers_json = serde_json::to_string(&all_headers).unwrap_or_else(|_| "{}".to_string());
1465
1466 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
1468
1469 let flows_data: Vec<serde_json::Value> = flows.iter().map(|f| {
1470 let sanitized_name = K6ScriptGenerator::sanitize_js_identifier(&f.name);
1472 serde_json::json!({
1473 "name": sanitized_name.clone(), "display_name": f.name, "base_path": f.base_path,
1476 "steps": f.steps.iter().enumerate().map(|(idx, s)| {
1477 let parts: Vec<&str> = s.operation.splitn(2, ' ').collect();
1479 let method_raw = if !parts.is_empty() {
1480 parts[0].to_uppercase()
1481 } else {
1482 "GET".to_string()
1483 };
1484 let method = if !parts.is_empty() {
1485 let m = parts[0].to_lowercase();
1486 if m == "delete" { "del".to_string() } else { m }
1488 } else {
1489 "get".to_string()
1490 };
1491 let raw_path = if parts.len() >= 2 { parts[1] } else { "/" };
1492 let path = if let Some(ref bp) = api_base_path {
1494 format!("{}{}", bp, raw_path)
1495 } else {
1496 raw_path.to_string()
1497 };
1498 let is_get_or_head = method == "get" || method == "head";
1499 let has_body = matches!(method.as_str(), "post" | "put" | "patch");
1501
1502 let body_value = if has_body {
1504 param_overrides.as_ref()
1505 .map(|po| po.get_for_operation(None, &method_raw, raw_path))
1506 .and_then(|oo| oo.body)
1507 .unwrap_or_else(|| serde_json::json!({}))
1508 } else {
1509 serde_json::json!({})
1510 };
1511
1512 let processed_body = DynamicParamProcessor::process_json_body(&body_value);
1514 let body_has_extracted_placeholders = processed_body.value.contains("${extracted.");
1519 let body_is_dynamic = processed_body.is_dynamic || body_has_extracted_placeholders;
1520
1521 serde_json::json!({
1522 "operation": s.operation,
1523 "method": method,
1524 "path": path,
1525 "extract": s.extract,
1526 "use_values": s.use_values,
1527 "use_body": s.use_body,
1528 "merge_body": if s.merge_body.is_empty() { None } else { Some(&s.merge_body) },
1529 "inject_attacks": s.inject_attacks,
1530 "attack_types": s.attack_types,
1531 "description": s.description,
1532 "display_name": s.description.clone().unwrap_or_else(|| format!("Step {}", idx)),
1533 "is_get_or_head": is_get_or_head,
1534 "has_body": has_body,
1535 "body": processed_body.value,
1536 "body_is_dynamic": body_is_dynamic,
1537 "_placeholders": processed_body.placeholders.iter().map(|p| format!("{:?}", p)).collect::<Vec<_>>(),
1538 })
1539 }).collect::<Vec<_>>(),
1540 })
1541 }).collect();
1542
1543 for flow_data in &flows_data {
1545 if let Some(steps) = flow_data.get("steps").and_then(|s| s.as_array()) {
1546 for step in steps {
1547 if let Some(placeholders_arr) =
1548 step.get("_placeholders").and_then(|p| p.as_array())
1549 {
1550 for p_str in placeholders_arr {
1551 if let Some(p_name) = p_str.as_str() {
1552 match p_name {
1554 "VU" => {
1555 all_placeholders.insert(DynamicPlaceholder::VU);
1556 }
1557 "Iteration" => {
1558 all_placeholders.insert(DynamicPlaceholder::Iteration);
1559 }
1560 "Timestamp" => {
1561 all_placeholders.insert(DynamicPlaceholder::Timestamp);
1562 }
1563 "UUID" => {
1564 all_placeholders.insert(DynamicPlaceholder::UUID);
1565 }
1566 "Random" => {
1567 all_placeholders.insert(DynamicPlaceholder::Random);
1568 }
1569 "Counter" => {
1570 all_placeholders.insert(DynamicPlaceholder::Counter);
1571 }
1572 "Date" => {
1573 all_placeholders.insert(DynamicPlaceholder::Date);
1574 }
1575 "VuIter" => {
1576 all_placeholders.insert(DynamicPlaceholder::VuIter);
1577 }
1578 _ => {}
1579 }
1580 }
1581 }
1582 }
1583 }
1584 }
1585 }
1586
1587 let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
1589 let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
1590
1591 let invalid_data_config = self.build_invalid_data_config();
1593 let error_injection_enabled = invalid_data_config.is_some();
1594 let error_rate = self.error_rate.unwrap_or(0.0);
1595 let error_types: Vec<String> = invalid_data_config
1596 .as_ref()
1597 .map(|c| c.error_types.iter().map(|t| format!("{:?}", t)).collect())
1598 .unwrap_or_default();
1599
1600 if error_injection_enabled {
1601 TerminalReporter::print_progress(&format!(
1602 "Error injection enabled ({}% rate)",
1603 (error_rate * 100.0) as u32
1604 ));
1605 }
1606
1607 let data = serde_json::json!({
1608 "base_url": self.target,
1609 "flows": flows_data,
1610 "extract_fields": config.default_extract_fields,
1611 "duration_secs": duration_secs,
1612 "max_vus": self.vus,
1613 "auth_header": self.auth,
1614 "custom_headers": custom_headers,
1615 "skip_tls_verify": self.skip_tls_verify,
1616 "stages": stages.iter().map(|s| serde_json::json!({
1618 "duration": s.duration,
1619 "target": s.target,
1620 })).collect::<Vec<_>>(),
1621 "threshold_percentile": self.threshold_percentile,
1622 "threshold_ms": self.threshold_ms,
1623 "max_error_rate": self.max_error_rate,
1624 "headers": headers_json,
1625 "dynamic_imports": required_imports,
1626 "dynamic_globals": required_globals,
1627 "error_injection_enabled": error_injection_enabled,
1629 "error_rate": error_rate,
1630 "error_types": error_types,
1631 });
1632
1633 let script = handlebars
1634 .render_template(template, &data)
1635 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
1636
1637 TerminalReporter::print_progress("Validating CRUD flow script...");
1639 let validation_errors = K6ScriptGenerator::validate_script(&script);
1640 if !validation_errors.is_empty() {
1641 TerminalReporter::print_error("CRUD flow script validation failed");
1642 for error in &validation_errors {
1643 eprintln!(" {}", error);
1644 }
1645 return Err(BenchError::Other(format!(
1646 "CRUD flow script validation failed with {} error(s)",
1647 validation_errors.len()
1648 )));
1649 }
1650
1651 TerminalReporter::print_success("CRUD flow script generated");
1652
1653 let script_path = if let Some(output) = &self.script_output {
1655 output.clone()
1656 } else {
1657 self.output.join("k6-crud-flow-script.js")
1658 };
1659
1660 if let Some(parent) = script_path.parent() {
1661 std::fs::create_dir_all(parent)?;
1662 }
1663 std::fs::write(&script_path, &script)?;
1664 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1665
1666 if self.generate_only {
1667 println!("\nScript generated successfully. Run it with:");
1668 println!(" k6 run {}", script_path.display());
1669 return Ok(());
1670 }
1671
1672 TerminalReporter::print_progress("Executing CRUD flow test...");
1674 let executor = K6Executor::new()?;
1675 std::fs::create_dir_all(&self.output)?;
1676
1677 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1678
1679 let duration_secs = Self::parse_duration(&self.duration)?;
1680 TerminalReporter::print_summary(&results, duration_secs);
1681
1682 Ok(())
1683 }
1684
1685 async fn execute_owasp_test(&self, parser: &SpecParser) -> Result<()> {
1687 TerminalReporter::print_progress("OWASP API Security Top 10 Testing Mode");
1688
1689 let mut config = OwaspApiConfig::new()
1691 .with_auth_header(&self.owasp_auth_header)
1692 .with_verbose(self.verbose)
1693 .with_insecure(self.skip_tls_verify);
1694
1695 if let Some(ref token) = self.owasp_auth_token {
1697 config = config.with_valid_auth_token(token);
1698 }
1699
1700 if let Some(ref cats_str) = self.owasp_categories {
1702 let categories: Vec<OwaspCategory> = cats_str
1703 .split(',')
1704 .filter_map(|s| {
1705 let trimmed = s.trim();
1706 match trimmed.parse::<OwaspCategory>() {
1707 Ok(cat) => Some(cat),
1708 Err(e) => {
1709 TerminalReporter::print_warning(&e);
1710 None
1711 }
1712 }
1713 })
1714 .collect();
1715
1716 if !categories.is_empty() {
1717 config = config.with_categories(categories);
1718 }
1719 }
1720
1721 if let Some(ref admin_paths_file) = self.owasp_admin_paths {
1723 config.admin_paths_file = Some(admin_paths_file.clone());
1724 if let Err(e) = config.load_admin_paths() {
1725 TerminalReporter::print_warning(&format!("Failed to load admin paths file: {}", e));
1726 }
1727 }
1728
1729 if let Some(ref id_fields_str) = self.owasp_id_fields {
1731 let id_fields: Vec<String> = id_fields_str
1732 .split(',')
1733 .map(|s| s.trim().to_string())
1734 .filter(|s| !s.is_empty())
1735 .collect();
1736 if !id_fields.is_empty() {
1737 config = config.with_id_fields(id_fields);
1738 }
1739 }
1740
1741 if let Some(ref report_path) = self.owasp_report {
1743 config = config.with_report_path(report_path);
1744 }
1745 if let Ok(format) = self.owasp_report_format.parse::<ReportFormat>() {
1746 config = config.with_report_format(format);
1747 }
1748
1749 let categories = config.categories_to_test();
1751 TerminalReporter::print_success(&format!(
1752 "Testing {} OWASP categories: {}",
1753 categories.len(),
1754 categories.iter().map(|c| c.cli_name()).collect::<Vec<_>>().join(", ")
1755 ));
1756
1757 if config.valid_auth_token.is_some() {
1758 TerminalReporter::print_progress("Using provided auth token for baseline requests");
1759 }
1760
1761 TerminalReporter::print_progress("Generating OWASP security test script...");
1763 let generator = OwaspApiGenerator::new(config, self.target.clone(), parser);
1764
1765 let script = generator.generate()?;
1767 TerminalReporter::print_success("OWASP security test script generated");
1768
1769 let script_path = if let Some(output) = &self.script_output {
1771 output.clone()
1772 } else {
1773 self.output.join("k6-owasp-security-test.js")
1774 };
1775
1776 if let Some(parent) = script_path.parent() {
1777 std::fs::create_dir_all(parent)?;
1778 }
1779 std::fs::write(&script_path, &script)?;
1780 TerminalReporter::print_success(&format!("Script written to: {}", script_path.display()));
1781
1782 if self.generate_only {
1784 println!("\nOWASP security test script generated. Run it with:");
1785 println!(" k6 run {}", script_path.display());
1786 return Ok(());
1787 }
1788
1789 TerminalReporter::print_progress("Executing OWASP security tests...");
1791 let executor = K6Executor::new()?;
1792 std::fs::create_dir_all(&self.output)?;
1793
1794 let results = executor.execute(&script_path, Some(&self.output), self.verbose).await?;
1795
1796 let duration_secs = Self::parse_duration(&self.duration)?;
1797 TerminalReporter::print_summary(&results, duration_secs);
1798
1799 println!("\nOWASP security test results saved to: {}", self.output.display());
1800
1801 Ok(())
1802 }
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807 use super::*;
1808
1809 #[test]
1810 fn test_parse_duration() {
1811 assert_eq!(BenchCommand::parse_duration("30s").unwrap(), 30);
1812 assert_eq!(BenchCommand::parse_duration("5m").unwrap(), 300);
1813 assert_eq!(BenchCommand::parse_duration("1h").unwrap(), 3600);
1814 assert_eq!(BenchCommand::parse_duration("60").unwrap(), 60);
1815 }
1816
1817 #[test]
1818 fn test_parse_duration_invalid() {
1819 assert!(BenchCommand::parse_duration("invalid").is_err());
1820 assert!(BenchCommand::parse_duration("30x").is_err());
1821 }
1822
1823 #[test]
1824 fn test_parse_headers() {
1825 let cmd = BenchCommand {
1826 spec: vec![PathBuf::from("test.yaml")],
1827 spec_dir: None,
1828 merge_conflicts: "error".to_string(),
1829 spec_mode: "merge".to_string(),
1830 dependency_config: None,
1831 target: "http://localhost".to_string(),
1832 base_path: None,
1833 duration: "1m".to_string(),
1834 vus: 10,
1835 scenario: "ramp-up".to_string(),
1836 operations: None,
1837 exclude_operations: None,
1838 auth: None,
1839 headers: Some("X-API-Key:test123,X-Client-ID:client456".to_string()),
1840 output: PathBuf::from("output"),
1841 generate_only: false,
1842 script_output: None,
1843 threshold_percentile: "p(95)".to_string(),
1844 threshold_ms: 500,
1845 max_error_rate: 0.05,
1846 verbose: false,
1847 skip_tls_verify: false,
1848 targets_file: None,
1849 max_concurrency: None,
1850 results_format: "both".to_string(),
1851 params_file: None,
1852 crud_flow: false,
1853 flow_config: None,
1854 extract_fields: None,
1855 parallel_create: None,
1856 data_file: None,
1857 data_distribution: "unique-per-vu".to_string(),
1858 data_mappings: None,
1859 per_uri_control: false,
1860 error_rate: None,
1861 error_types: None,
1862 security_test: false,
1863 security_payloads: None,
1864 security_categories: None,
1865 security_target_fields: None,
1866 wafbench_dir: None,
1867 owasp_api_top10: false,
1868 owasp_categories: None,
1869 owasp_auth_header: "Authorization".to_string(),
1870 owasp_auth_token: None,
1871 owasp_admin_paths: None,
1872 owasp_id_fields: None,
1873 owasp_report: None,
1874 owasp_report_format: "json".to_string(),
1875 };
1876
1877 let headers = cmd.parse_headers().unwrap();
1878 assert_eq!(headers.get("X-API-Key"), Some(&"test123".to_string()));
1879 assert_eq!(headers.get("X-Client-ID"), Some(&"client456".to_string()));
1880 }
1881
1882 #[test]
1883 fn test_get_spec_display_name() {
1884 let cmd = BenchCommand {
1885 spec: vec![PathBuf::from("test.yaml")],
1886 spec_dir: None,
1887 merge_conflicts: "error".to_string(),
1888 spec_mode: "merge".to_string(),
1889 dependency_config: None,
1890 target: "http://localhost".to_string(),
1891 base_path: None,
1892 duration: "1m".to_string(),
1893 vus: 10,
1894 scenario: "ramp-up".to_string(),
1895 operations: None,
1896 exclude_operations: None,
1897 auth: None,
1898 headers: None,
1899 output: PathBuf::from("output"),
1900 generate_only: false,
1901 script_output: None,
1902 threshold_percentile: "p(95)".to_string(),
1903 threshold_ms: 500,
1904 max_error_rate: 0.05,
1905 verbose: false,
1906 skip_tls_verify: false,
1907 targets_file: None,
1908 max_concurrency: None,
1909 results_format: "both".to_string(),
1910 params_file: None,
1911 crud_flow: false,
1912 flow_config: None,
1913 extract_fields: None,
1914 parallel_create: None,
1915 data_file: None,
1916 data_distribution: "unique-per-vu".to_string(),
1917 data_mappings: None,
1918 per_uri_control: false,
1919 error_rate: None,
1920 error_types: None,
1921 security_test: false,
1922 security_payloads: None,
1923 security_categories: None,
1924 security_target_fields: None,
1925 wafbench_dir: None,
1926 owasp_api_top10: false,
1927 owasp_categories: None,
1928 owasp_auth_header: "Authorization".to_string(),
1929 owasp_auth_token: None,
1930 owasp_admin_paths: None,
1931 owasp_id_fields: None,
1932 owasp_report: None,
1933 owasp_report_format: "json".to_string(),
1934 };
1935
1936 assert_eq!(cmd.get_spec_display_name(), "test.yaml");
1937
1938 let cmd_multi = BenchCommand {
1940 spec: vec![PathBuf::from("a.yaml"), PathBuf::from("b.yaml")],
1941 spec_dir: None,
1942 merge_conflicts: "error".to_string(),
1943 spec_mode: "merge".to_string(),
1944 dependency_config: None,
1945 target: "http://localhost".to_string(),
1946 base_path: None,
1947 duration: "1m".to_string(),
1948 vus: 10,
1949 scenario: "ramp-up".to_string(),
1950 operations: None,
1951 exclude_operations: None,
1952 auth: None,
1953 headers: None,
1954 output: PathBuf::from("output"),
1955 generate_only: false,
1956 script_output: None,
1957 threshold_percentile: "p(95)".to_string(),
1958 threshold_ms: 500,
1959 max_error_rate: 0.05,
1960 verbose: false,
1961 skip_tls_verify: false,
1962 targets_file: None,
1963 max_concurrency: None,
1964 results_format: "both".to_string(),
1965 params_file: None,
1966 crud_flow: false,
1967 flow_config: None,
1968 extract_fields: None,
1969 parallel_create: None,
1970 data_file: None,
1971 data_distribution: "unique-per-vu".to_string(),
1972 data_mappings: None,
1973 per_uri_control: false,
1974 error_rate: None,
1975 error_types: None,
1976 security_test: false,
1977 security_payloads: None,
1978 security_categories: None,
1979 security_target_fields: None,
1980 wafbench_dir: None,
1981 owasp_api_top10: false,
1982 owasp_categories: None,
1983 owasp_auth_header: "Authorization".to_string(),
1984 owasp_auth_token: None,
1985 owasp_admin_paths: None,
1986 owasp_id_fields: None,
1987 owasp_report: None,
1988 owasp_report_format: "json".to_string(),
1989 };
1990
1991 assert_eq!(cmd_multi.get_spec_display_name(), "2 spec files");
1992 }
1993}