1use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
4use crate::error::{BenchError, Result};
5use crate::request_gen::RequestTemplate;
6use crate::scenarios::LoadScenario;
7use handlebars::Handlebars;
8use serde::Serialize;
9use serde_json::Value;
10use std::collections::{HashMap, HashSet};
11
12#[derive(Debug, Clone, Serialize)]
18pub struct K6ScriptTemplateData {
19 pub base_url: String,
20 pub stages: Vec<K6StageData>,
21 pub operations: Vec<K6OperationData>,
22 pub threshold_percentile: String,
23 pub threshold_ms: u64,
24 pub max_error_rate: f64,
25 pub scenario_name: String,
26 pub skip_tls_verify: bool,
27 pub has_dynamic_values: bool,
28 pub dynamic_imports: Vec<String>,
29 pub dynamic_globals: Vec<String>,
30 pub security_testing_enabled: bool,
31 pub has_custom_headers: bool,
32 pub chunked_request_bodies: bool,
40 pub target_rps: Option<u32>,
44 pub no_keep_alive: bool,
48 pub duration_secs: u64,
55 pub max_vus: u32,
58 pub start_vus: u32,
69 pub geo_source_ips: Vec<String>,
77 pub geo_source_headers: Vec<String>,
82 pub has_geo_source: bool,
87 pub geo_source_ips_json: String,
92 pub geo_source_headers_json: String,
94}
95
96#[derive(Debug, Clone, Serialize)]
98pub struct K6CrudFlowTemplateData {
99 pub base_url: String,
100 pub flows: Vec<Value>,
101 pub extract_fields: Vec<String>,
102 pub duration_secs: u64,
103 pub max_vus: u32,
104 pub auth_header: Option<String>,
105 pub custom_headers: HashMap<String, String>,
106 pub skip_tls_verify: bool,
107 pub stages: Vec<K6StageData>,
108 pub threshold_percentile: String,
109 pub threshold_ms: u64,
110 pub max_error_rate: f64,
111 pub headers: String,
113 pub dynamic_imports: Vec<String>,
114 pub dynamic_globals: Vec<String>,
115 pub extracted_values_output_path: String,
116 pub error_injection_enabled: bool,
117 pub error_rate: f64,
118 pub error_types: Vec<String>,
119 pub security_testing_enabled: bool,
120 pub has_custom_headers: bool,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct K6StageData {
126 pub duration: String,
127 pub target: u32,
128}
129
130#[derive(Debug, Clone, Serialize)]
132pub struct K6OperationData {
133 pub index: usize,
134 pub name: String,
135 pub metric_name: String,
136 pub display_name: String,
137 pub method: String,
138 pub path: Value,
139 pub path_is_dynamic: bool,
140 pub headers: Value,
141 pub body: Option<Value>,
142 pub body_is_dynamic: bool,
143 pub has_body: bool,
144 pub is_get_or_head: bool,
145}
146
147pub struct K6Config {
149 pub target_url: String,
150 pub base_path: Option<String>,
153 pub scenario: LoadScenario,
154 pub duration_secs: u64,
155 pub max_vus: u32,
156 pub threshold_percentile: String,
157 pub threshold_ms: u64,
158 pub max_error_rate: f64,
159 pub auth_header: Option<String>,
160 pub custom_headers: HashMap<String, String>,
161 pub skip_tls_verify: bool,
162 pub security_testing_enabled: bool,
163 pub chunked_request_bodies: bool,
166 pub target_rps: Option<u32>,
169 pub no_keep_alive: bool,
172 pub geo_source_ips: Vec<String>,
176 pub geo_source_headers: Vec<String>,
180}
181
182pub struct K6ScriptGenerator {
184 config: K6Config,
185 templates: Vec<RequestTemplate>,
186}
187
188impl K6ScriptGenerator {
189 pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
191 Self { config, templates }
192 }
193
194 pub fn generate(&self) -> Result<String> {
196 let handlebars = Handlebars::new();
197
198 let template = include_str!("templates/k6_script.hbs");
199
200 let data = self.build_template_data()?;
201
202 let value = serde_json::to_value(&data)
203 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
204
205 handlebars
206 .render_template(template, &value)
207 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
208 }
209
210 const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
216
217 pub fn sanitize_k6_metric_name(name: &str) -> String {
230 let sanitized = Self::sanitize_js_identifier(name);
231 if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
232 return sanitized;
233 }
234
235 use std::collections::hash_map::DefaultHasher;
236 use std::hash::{Hash, Hasher};
237 let mut hasher = DefaultHasher::new();
238 name.hash(&mut hasher);
242 let hash_suffix = format!("{:08x}", hasher.finish() as u32);
243
244 let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
246 let prefix = &sanitized[..prefix_len];
247 let prefix = prefix.trim_end_matches('_');
249 format!("{}_{}", prefix, hash_suffix)
250 }
251
252 pub fn sanitize_js_identifier(name: &str) -> String {
262 let mut result = String::new();
263 let mut chars = name.chars().peekable();
264
265 if let Some(&first) = chars.peek() {
267 if first.is_ascii_digit() {
268 result.push('_');
269 }
270 }
271
272 for ch in chars {
273 if ch.is_ascii_alphanumeric() || ch == '_' {
274 result.push(ch);
275 } else {
276 if !result.ends_with('_') {
279 result.push('_');
280 }
281 }
282 }
283
284 result = result.trim_end_matches('_').to_string();
286
287 if result.is_empty() {
289 result = "operation".to_string();
290 }
291
292 result
293 }
294
295 fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
297 let stages = self
298 .config
299 .scenario
300 .generate_stages(self.config.duration_secs, self.config.max_vus);
301
302 let base_path = self.config.base_path.as_deref().unwrap_or("");
304
305 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
307
308 let operations = self
309 .templates
310 .iter()
311 .enumerate()
312 .map(|(idx, template)| {
313 let display_name = template.operation.display_name();
314 let sanitized_name = Self::sanitize_js_identifier(&display_name);
315 let metric_name = Self::sanitize_k6_metric_name(&display_name);
321 let k6_method = match template.operation.method.to_lowercase().as_str() {
323 "delete" => "del".to_string(),
324 m => m.to_string(),
325 };
326 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
329
330 let raw_path = template.generate_path();
333 let full_path = if base_path.is_empty() {
334 raw_path
335 } else {
336 format!("{}{}", base_path, raw_path)
337 };
338 let processed_path = DynamicParamProcessor::process_path(&full_path);
339 all_placeholders.extend(processed_path.placeholders.clone());
340
341 let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
343 let processed_body = DynamicParamProcessor::process_json_body(body);
344 all_placeholders.extend(processed_body.placeholders.clone());
345 (Some(processed_body.value), processed_body.is_dynamic)
346 } else {
347 (None, false)
348 };
349
350 let path_value = if processed_path.is_dynamic {
351 processed_path.value
352 } else {
353 full_path
354 };
355
356 K6OperationData {
357 index: idx,
358 name: sanitized_name,
359 metric_name,
360 display_name,
361 method: k6_method,
362 path: Value::String(path_value),
363 path_is_dynamic: processed_path.is_dynamic,
364 headers: Value::String(self.build_headers_json(template)),
365 body: body_value.map(Value::String),
366 body_is_dynamic,
367 has_body: template.body.is_some(),
368 is_get_or_head,
369 }
370 })
371 .collect::<Vec<_>>();
372
373 let required_imports: Vec<String> =
375 DynamicParamProcessor::get_required_imports(&all_placeholders)
376 .into_iter()
377 .map(String::from)
378 .collect();
379 let required_globals: Vec<String> =
380 DynamicParamProcessor::get_required_globals(&all_placeholders)
381 .into_iter()
382 .map(String::from)
383 .collect();
384 let has_dynamic_values = !all_placeholders.is_empty();
385
386 Ok(K6ScriptTemplateData {
387 base_url: self.config.target_url.clone(),
388 stages: stages
389 .iter()
390 .map(|s| K6StageData {
391 duration: s.duration.clone(),
392 target: s.target,
393 })
394 .collect(),
395 operations,
396 threshold_percentile: self.config.threshold_percentile.clone(),
397 threshold_ms: self.config.threshold_ms,
398 max_error_rate: self.config.max_error_rate,
399 scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
400 skip_tls_verify: self.config.skip_tls_verify,
401 has_dynamic_values,
402 dynamic_imports: required_imports,
403 dynamic_globals: required_globals,
404 security_testing_enabled: self.config.security_testing_enabled,
405 has_custom_headers: !self.config.custom_headers.is_empty(),
406 chunked_request_bodies: self.config.chunked_request_bodies,
407 target_rps: self.config.target_rps,
408 no_keep_alive: self.config.no_keep_alive,
409 duration_secs: self.config.duration_secs,
410 max_vus: self.config.max_vus,
411 start_vus: match self.config.scenario {
415 LoadScenario::Constant => self.config.max_vus,
416 _ => 0,
417 },
418 geo_source_ips: self.config.geo_source_ips.clone(),
426 geo_source_headers: self.config.geo_source_headers.clone(),
427 has_geo_source: !self.config.geo_source_ips.is_empty()
428 && !self.config.geo_source_headers.is_empty(),
429 geo_source_ips_json: serde_json::to_string(&self.config.geo_source_ips)
430 .unwrap_or_else(|_| "[]".to_string()),
431 geo_source_headers_json: serde_json::to_string(&self.config.geo_source_headers)
432 .unwrap_or_else(|_| "[]".to_string()),
433 })
434 }
435
436 fn build_headers_json(&self, template: &RequestTemplate) -> String {
438 let mut headers = template.get_headers();
439
440 if let Some(auth) = &self.config.auth_header {
442 headers.insert("Authorization".to_string(), auth.clone());
443 }
444
445 for (key, value) in &self.config.custom_headers {
447 headers.insert(key.clone(), value.clone());
448 }
449
450 if self.config.chunked_request_bodies && template.body.is_some() {
455 headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
456 }
457
458 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
460 }
461
462 pub fn validate_script(script: &str) -> Vec<String> {
471 let mut errors = Vec::new();
472
473 if !script.contains("import http from 'k6/http'") {
475 errors.push("Missing required import: 'k6/http'".to_string());
476 }
477 if !script.contains("import { check") && !script.contains("import {check") {
478 errors.push("Missing required import: 'check' from 'k6'".to_string());
479 }
480 if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
481 errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
482 }
483
484 let lines: Vec<&str> = script.lines().collect();
488 for (line_num, line) in lines.iter().enumerate() {
489 let trimmed = line.trim();
490
491 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
493 if let Some(start) = trimmed.find('\'') {
496 if let Some(end) = trimmed[start + 1..].find('\'') {
497 let metric_name = &trimmed[start + 1..start + 1 + end];
498 if !Self::is_valid_k6_metric_name(metric_name) {
499 errors.push(format!(
500 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
501 line_num + 1,
502 metric_name
503 ));
504 }
505 }
506 } else if let Some(start) = trimmed.find('"') {
507 if let Some(end) = trimmed[start + 1..].find('"') {
508 let metric_name = &trimmed[start + 1..start + 1 + end];
509 if !Self::is_valid_k6_metric_name(metric_name) {
510 errors.push(format!(
511 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
512 line_num + 1,
513 metric_name
514 ));
515 }
516 }
517 }
518 }
519
520 if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
522 if let Some(equals_pos) = trimmed.find('=') {
523 let var_decl = &trimmed[..equals_pos];
524 if var_decl.contains('.')
527 && !var_decl.contains("'")
528 && !var_decl.contains("\"")
529 && !var_decl.trim().starts_with("//")
530 {
531 errors.push(format!(
532 "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
533 line_num + 1,
534 var_decl.trim()
535 ));
536 }
537 }
538 }
539 }
540
541 errors
542 }
543
544 fn is_valid_k6_metric_name(name: &str) -> bool {
551 if name.is_empty() || name.len() > 128 {
552 return false;
553 }
554
555 let mut chars = name.chars();
556
557 if let Some(first) = chars.next() {
559 if !first.is_ascii_alphabetic() && first != '_' {
560 return false;
561 }
562 }
563
564 for ch in chars {
566 if !ch.is_ascii_alphanumeric() && ch != '_' {
567 return false;
568 }
569 }
570
571 true
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_k6_config_creation() {
581 let config = K6Config {
582 target_url: "https://api.example.com".to_string(),
583 base_path: None,
584 scenario: LoadScenario::RampUp,
585 duration_secs: 60,
586 max_vus: 10,
587 threshold_percentile: "p(95)".to_string(),
588 threshold_ms: 500,
589 max_error_rate: 0.05,
590 auth_header: None,
591 custom_headers: HashMap::new(),
592 skip_tls_verify: false,
593 security_testing_enabled: false,
594 chunked_request_bodies: false,
595 target_rps: None,
596 no_keep_alive: false,
597 geo_source_ips: Vec::new(),
598 geo_source_headers: Vec::new(),
599 };
600
601 assert_eq!(config.duration_secs, 60);
602 assert_eq!(config.max_vus, 10);
603 }
604
605 #[test]
606 fn test_script_generator_creation() {
607 let config = K6Config {
608 target_url: "https://api.example.com".to_string(),
609 base_path: None,
610 scenario: LoadScenario::Constant,
611 duration_secs: 30,
612 max_vus: 5,
613 threshold_percentile: "p(95)".to_string(),
614 threshold_ms: 500,
615 max_error_rate: 0.05,
616 auth_header: None,
617 custom_headers: HashMap::new(),
618 skip_tls_verify: false,
619 security_testing_enabled: false,
620 chunked_request_bodies: false,
621 target_rps: None,
622 no_keep_alive: false,
623 geo_source_ips: Vec::new(),
624 geo_source_headers: Vec::new(),
625 };
626
627 let templates = vec![];
628 let generator = K6ScriptGenerator::new(config, templates);
629
630 assert_eq!(generator.templates.len(), 0);
631 }
632
633 #[test]
634 fn test_sanitize_js_identifier() {
635 assert_eq!(
637 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
638 "billing_subscriptions_v1"
639 );
640
641 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
643
644 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
646
647 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
649
650 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
652
653 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
655
656 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
658
659 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
661 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
662 assert_eq!(
663 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
664 "plans_update_pricing_schemes"
665 );
666 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
667 }
668
669 #[test]
670 fn test_sanitize_k6_metric_name_short_passthrough() {
671 let short = "billing_subscriptions_list";
673 let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
674 assert_eq!(out, short);
675 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
676 }
677
678 #[test]
679 fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
680 let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
684 charts.workbookChart.axes.categoryAxis.format.line.clear";
685 let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
686
687 assert!(
689 metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
690 "metric base len {} exceeded cap {}",
691 metric.len(),
692 K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
693 );
694
695 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
697 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
698 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
699 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
701 }
702
703 #[test]
704 fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
705 let prefix = "a".repeat(150);
708 let a = format!("{prefix}.foo");
709 let b = format!("{prefix}.bar");
710 let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
711 let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
712 assert_ne!(ma, mb, "distinct long names produced the same metric name");
713 }
714
715 #[test]
716 fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
717 let long = format!("{}123end", "x".repeat(120));
719 let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
720 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
721 }
722
723 #[test]
724 fn test_microsoft_graph_long_operation_id_passes_validation() {
725 use crate::spec_parser::ApiOperation;
728 use openapiv3::Operation;
729
730 let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
731 workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
732 line.clear";
733
734 let operation = ApiOperation {
735 method: "post".to_string(),
736 path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
737 operation: Operation::default(),
738 operation_id: Some(long_op_id.to_string()),
739 };
740 let template = RequestTemplate {
741 operation,
742 path_params: HashMap::new(),
743 query_params: HashMap::new(),
744 headers: HashMap::new(),
745 body: None,
746 };
747 let config = K6Config {
748 target_url: "https://api.example.com".to_string(),
749 base_path: Some("/v1.0".to_string()),
750 scenario: LoadScenario::Constant,
751 duration_secs: 30,
752 max_vus: 5,
753 threshold_percentile: "p(95)".to_string(),
754 threshold_ms: 500,
755 max_error_rate: 0.05,
756 auth_header: None,
757 custom_headers: HashMap::new(),
758 skip_tls_verify: false,
759 security_testing_enabled: false,
760 chunked_request_bodies: false,
761 target_rps: None,
762 no_keep_alive: false,
763 geo_source_ips: Vec::new(),
764 geo_source_headers: Vec::new(),
765 };
766 let generator = K6ScriptGenerator::new(config, vec![template]);
767 let script = generator.generate().expect("script generates");
768
769 let errors = K6ScriptGenerator::validate_script(&script);
770 assert!(
771 errors.is_empty(),
772 "validate_script returned errors for long operationId: {errors:#?}"
773 );
774 }
775
776 #[test]
777 fn test_script_generation_with_dots_in_name() {
778 use crate::spec_parser::ApiOperation;
779 use openapiv3::Operation;
780
781 let operation = ApiOperation {
783 method: "get".to_string(),
784 path: "/billing/subscriptions".to_string(),
785 operation: Operation::default(),
786 operation_id: Some("billing.subscriptions.v1".to_string()),
787 };
788
789 let template = RequestTemplate {
790 operation,
791 path_params: HashMap::new(),
792 query_params: HashMap::new(),
793 headers: HashMap::new(),
794 body: None,
795 };
796
797 let config = K6Config {
798 target_url: "https://api.example.com".to_string(),
799 base_path: None,
800 scenario: LoadScenario::Constant,
801 duration_secs: 30,
802 max_vus: 5,
803 threshold_percentile: "p(95)".to_string(),
804 threshold_ms: 500,
805 max_error_rate: 0.05,
806 auth_header: None,
807 custom_headers: HashMap::new(),
808 skip_tls_verify: false,
809 security_testing_enabled: false,
810 chunked_request_bodies: false,
811 target_rps: None,
812 no_keep_alive: false,
813 geo_source_ips: Vec::new(),
814 geo_source_headers: Vec::new(),
815 };
816
817 let generator = K6ScriptGenerator::new(config, vec![template]);
818 let script = generator.generate().expect("Should generate script");
819
820 assert!(
822 script.contains("const billing_subscriptions_v1_latency"),
823 "Script should contain sanitized variable name for latency"
824 );
825 assert!(
826 script.contains("const billing_subscriptions_v1_errors"),
827 "Script should contain sanitized variable name for errors"
828 );
829
830 assert!(
833 !script.contains("const billing.subscriptions"),
834 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
835 );
836
837 assert!(
840 script.contains("'billing_subscriptions_v1_latency'"),
841 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
842 );
843 assert!(
844 script.contains("'billing_subscriptions_v1_errors'"),
845 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
846 );
847
848 assert!(
850 script.contains("billing.subscriptions.v1"),
851 "Script should contain original name in comments/strings for readability"
852 );
853
854 assert!(
856 script.contains("billing_subscriptions_v1_latency.add"),
857 "Variable usage should use sanitized name"
858 );
859 assert!(
860 script.contains("billing_subscriptions_v1_errors.add"),
861 "Variable usage should use sanitized name"
862 );
863 }
864
865 #[test]
872 fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
873 use crate::spec_parser::ApiOperation;
874 use openapiv3::Operation;
875
876 let operation = ApiOperation {
877 method: "get".to_string(),
878 path: "/users".to_string(),
879 operation: Operation::default(),
880 operation_id: Some("listUsers".to_string()),
881 };
882 let template = RequestTemplate {
883 operation,
884 path_params: HashMap::new(),
885 query_params: HashMap::new(),
886 headers: HashMap::new(),
887 body: None,
888 };
889
890 let config = K6Config {
891 target_url: "https://api.example.com".to_string(),
892 base_path: None,
893 scenario: LoadScenario::RampUp,
894 duration_secs: 600,
895 max_vus: 100,
896 threshold_percentile: "p(95)".to_string(),
897 threshold_ms: 500,
898 max_error_rate: 0.05,
899 auth_header: None,
900 custom_headers: HashMap::new(),
901 skip_tls_verify: false,
902 security_testing_enabled: false,
903 chunked_request_bodies: false,
904 target_rps: Some(100),
905 no_keep_alive: false,
906 geo_source_ips: Vec::new(),
907 geo_source_headers: Vec::new(),
908 };
909
910 let generator = K6ScriptGenerator::new(config, vec![template]);
911 let script = generator.generate().expect("Should generate script");
912
913 assert!(
914 script.contains("constant-arrival-rate"),
915 "with --rps set, executor must switch to constant-arrival-rate"
916 );
917 assert!(
918 script.contains("rate: 100,"),
919 "constant-arrival-rate must use the configured --rps as `rate`"
920 );
921 assert!(
922 script.contains("duration: '600s'"),
923 "duration must come from --duration, not the ramp-down stage; got:\n{}",
924 script
925 );
926 assert!(
927 script.contains("preAllocatedVUs: 100,"),
928 "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
929 script
930 );
931 assert!(
932 script.contains("maxVUs: 100,"),
933 "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
934 script
935 );
936 for (idx, line) in script.lines().enumerate() {
940 let trimmed = line.trim_start();
941 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
942 continue;
943 }
944 assert!(
945 !trimmed.starts_with("preAllocatedVUs: 0"),
946 "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
947 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
948 idx + 1,
949 line,
950 );
951 }
952 }
953
954 #[test]
957 fn test_cps_sets_no_connection_reuse() {
958 use crate::spec_parser::ApiOperation;
959 use openapiv3::Operation;
960
961 let operation = ApiOperation {
962 method: "get".to_string(),
963 path: "/u".to_string(),
964 operation: Operation::default(),
965 operation_id: Some("u".to_string()),
966 };
967 let template = RequestTemplate {
968 operation,
969 path_params: HashMap::new(),
970 query_params: HashMap::new(),
971 headers: HashMap::new(),
972 body: None,
973 };
974 let config = K6Config {
975 target_url: "https://api.example.com".to_string(),
976 base_path: None,
977 scenario: LoadScenario::Constant,
978 duration_secs: 30,
979 max_vus: 5,
980 threshold_percentile: "p(95)".to_string(),
981 threshold_ms: 500,
982 max_error_rate: 0.05,
983 auth_header: None,
984 custom_headers: HashMap::new(),
985 skip_tls_verify: false,
986 security_testing_enabled: false,
987 chunked_request_bodies: false,
988 target_rps: None,
989 no_keep_alive: true,
990 geo_source_ips: Vec::new(),
991 geo_source_headers: Vec::new(),
992 };
993 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
994 assert!(
995 script.contains("noConnectionReuse: true"),
996 "--cps must set noConnectionReuse: true on the k6 options block"
997 );
998 assert!(
999 script.contains("Total Connections:"),
1000 "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1001 );
1002 assert!(
1003 script.contains("Connection Rate:"),
1004 "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1005 );
1006 }
1007
1008 #[test]
1014 fn test_constant_scenario_starts_at_target_vus() {
1015 use crate::spec_parser::ApiOperation;
1016 use openapiv3::Operation;
1017
1018 let operation = ApiOperation {
1019 method: "get".to_string(),
1020 path: "/u".to_string(),
1021 operation: Operation::default(),
1022 operation_id: Some("u".to_string()),
1023 };
1024 let template = RequestTemplate {
1025 operation,
1026 path_params: HashMap::new(),
1027 query_params: HashMap::new(),
1028 headers: HashMap::new(),
1029 body: None,
1030 };
1031 let config = K6Config {
1032 target_url: "https://api.example.com".to_string(),
1033 base_path: None,
1034 scenario: LoadScenario::Constant,
1035 duration_secs: 600,
1036 max_vus: 5,
1037 threshold_percentile: "p(95)".to_string(),
1038 threshold_ms: 500,
1039 max_error_rate: 0.05,
1040 auth_header: None,
1041 custom_headers: HashMap::new(),
1042 skip_tls_verify: false,
1043 security_testing_enabled: false,
1044 chunked_request_bodies: false,
1045 target_rps: None,
1046 no_keep_alive: false,
1047 geo_source_ips: Vec::new(),
1048 geo_source_headers: Vec::new(),
1049 };
1050 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1051 assert!(
1052 script.contains("startVUs: 5,"),
1053 "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1054 script
1055 );
1056 let ramp_config = K6Config {
1058 target_url: "https://api.example.com".to_string(),
1059 base_path: None,
1060 scenario: LoadScenario::RampUp,
1061 duration_secs: 600,
1062 max_vus: 5,
1063 threshold_percentile: "p(95)".to_string(),
1064 threshold_ms: 500,
1065 max_error_rate: 0.05,
1066 auth_header: None,
1067 custom_headers: HashMap::new(),
1068 skip_tls_verify: false,
1069 security_testing_enabled: false,
1070 chunked_request_bodies: false,
1071 target_rps: None,
1072 no_keep_alive: false,
1073 geo_source_ips: Vec::new(),
1074 geo_source_headers: Vec::new(),
1075 };
1076 let ramp_template = RequestTemplate {
1077 operation: ApiOperation {
1078 method: "get".to_string(),
1079 path: "/u".to_string(),
1080 operation: Operation::default(),
1081 operation_id: Some("u".to_string()),
1082 },
1083 path_params: HashMap::new(),
1084 query_params: HashMap::new(),
1085 headers: HashMap::new(),
1086 body: None,
1087 };
1088 let ramp_script =
1089 K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1090 assert!(
1091 ramp_script.contains("startVUs: 0,"),
1092 "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1093 ramp_script
1094 );
1095 }
1096
1097 #[test]
1106 fn test_connections_opened_counter_present() {
1107 use crate::spec_parser::ApiOperation;
1108 use openapiv3::Operation;
1109
1110 let operation = ApiOperation {
1111 method: "get".to_string(),
1112 path: "/u".to_string(),
1113 operation: Operation::default(),
1114 operation_id: Some("u".to_string()),
1115 };
1116 let template = RequestTemplate {
1117 operation,
1118 path_params: HashMap::new(),
1119 query_params: HashMap::new(),
1120 headers: HashMap::new(),
1121 body: None,
1122 };
1123 let config = K6Config {
1124 target_url: "https://api.example.com".to_string(),
1125 base_path: None,
1126 scenario: LoadScenario::Constant,
1127 duration_secs: 30,
1128 max_vus: 5,
1129 threshold_percentile: "p(95)".to_string(),
1130 threshold_ms: 500,
1131 max_error_rate: 0.05,
1132 auth_header: None,
1133 custom_headers: HashMap::new(),
1134 skip_tls_verify: false,
1135 security_testing_enabled: false,
1136 chunked_request_bodies: false,
1137 target_rps: Some(50),
1138 no_keep_alive: false,
1139 geo_source_ips: Vec::new(),
1140 geo_source_headers: Vec::new(),
1141 };
1142 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1143 assert!(
1144 script.contains("new Counter('mockforge_connections_opened')"),
1145 "template must declare the mockforge_connections_opened Counter"
1146 );
1147 assert!(
1148 script.contains("mockforge_connections_opened.add(1)"),
1149 "template must increment mockforge_connections_opened on new TCP connect"
1150 );
1151 assert!(
1152 script.contains("res.timings.connecting > 0"),
1153 "template must gate the connection-opened increment on \
1154 res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1155 );
1156 }
1157
1158 #[test]
1159 fn test_validate_script_valid() {
1160 let valid_script = r#"
1161import http from 'k6/http';
1162import { check, sleep } from 'k6';
1163import { Rate, Trend } from 'k6/metrics';
1164
1165const test_latency = new Trend('test_latency');
1166const test_errors = new Rate('test_errors');
1167
1168export default function() {
1169 const res = http.get('https://example.com');
1170 test_latency.add(res.timings.duration);
1171 test_errors.add(res.status !== 200);
1172}
1173"#;
1174
1175 let errors = K6ScriptGenerator::validate_script(valid_script);
1176 assert!(errors.is_empty(), "Valid script should have no validation errors");
1177 }
1178
1179 #[test]
1180 fn test_validate_script_invalid_metric_name() {
1181 let invalid_script = r#"
1182import http from 'k6/http';
1183import { check, sleep } from 'k6';
1184import { Rate, Trend } from 'k6/metrics';
1185
1186const test_latency = new Trend('test.latency');
1187const test_errors = new Rate('test_errors');
1188
1189export default function() {
1190 const res = http.get('https://example.com');
1191 test_latency.add(res.timings.duration);
1192}
1193"#;
1194
1195 let errors = K6ScriptGenerator::validate_script(invalid_script);
1196 assert!(
1197 !errors.is_empty(),
1198 "Script with invalid metric name should have validation errors"
1199 );
1200 assert!(
1201 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1202 "Should detect invalid metric name with dot"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_validate_script_missing_imports() {
1208 let invalid_script = r#"
1209const test_latency = new Trend('test_latency');
1210export default function() {}
1211"#;
1212
1213 let errors = K6ScriptGenerator::validate_script(invalid_script);
1214 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1215 }
1216
1217 #[test]
1218 fn test_validate_script_metric_name_validation() {
1219 let valid_script = r#"
1222import http from 'k6/http';
1223import { check, sleep } from 'k6';
1224import { Rate, Trend } from 'k6/metrics';
1225const test_latency = new Trend('test_latency');
1226const test_errors = new Rate('test_errors');
1227export default function() {}
1228"#;
1229 let errors = K6ScriptGenerator::validate_script(valid_script);
1230 assert!(errors.is_empty(), "Valid metric names should pass validation");
1231
1232 let invalid_cases = vec![
1234 ("test.latency", "dot in metric name"),
1235 ("123test", "starts with number"),
1236 ("test-latency", "hyphen in metric name"),
1237 ("test@latency", "special character"),
1238 ];
1239
1240 for (invalid_name, description) in invalid_cases {
1241 let script = format!(
1242 r#"
1243import http from 'k6/http';
1244import {{ check, sleep }} from 'k6';
1245import {{ Rate, Trend }} from 'k6/metrics';
1246const test_latency = new Trend('{}');
1247export default function() {{}}
1248"#,
1249 invalid_name
1250 );
1251 let errors = K6ScriptGenerator::validate_script(&script);
1252 assert!(
1253 !errors.is_empty(),
1254 "Metric name '{}' ({}) should fail validation",
1255 invalid_name,
1256 description
1257 );
1258 }
1259 }
1260
1261 #[test]
1262 fn test_skip_tls_verify_with_body() {
1263 use crate::spec_parser::ApiOperation;
1264 use openapiv3::Operation;
1265 use serde_json::json;
1266
1267 let operation = ApiOperation {
1269 method: "post".to_string(),
1270 path: "/api/users".to_string(),
1271 operation: Operation::default(),
1272 operation_id: Some("createUser".to_string()),
1273 };
1274
1275 let template = RequestTemplate {
1276 operation,
1277 path_params: HashMap::new(),
1278 query_params: HashMap::new(),
1279 headers: HashMap::new(),
1280 body: Some(json!({"name": "test"})),
1281 };
1282
1283 let config = K6Config {
1284 target_url: "https://api.example.com".to_string(),
1285 base_path: None,
1286 scenario: LoadScenario::Constant,
1287 duration_secs: 30,
1288 max_vus: 5,
1289 threshold_percentile: "p(95)".to_string(),
1290 threshold_ms: 500,
1291 max_error_rate: 0.05,
1292 auth_header: None,
1293 custom_headers: HashMap::new(),
1294 skip_tls_verify: true,
1295 security_testing_enabled: false,
1296 chunked_request_bodies: false,
1297 target_rps: None,
1298 no_keep_alive: false,
1299 geo_source_ips: Vec::new(),
1300 geo_source_headers: Vec::new(),
1301 };
1302
1303 let generator = K6ScriptGenerator::new(config, vec![template]);
1304 let script = generator.generate().expect("Should generate script");
1305
1306 assert!(
1308 script.contains("insecureSkipTLSVerify: true"),
1309 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_skip_tls_verify_without_body() {
1315 use crate::spec_parser::ApiOperation;
1316 use openapiv3::Operation;
1317
1318 let operation = ApiOperation {
1320 method: "get".to_string(),
1321 path: "/api/users".to_string(),
1322 operation: Operation::default(),
1323 operation_id: Some("getUsers".to_string()),
1324 };
1325
1326 let template = RequestTemplate {
1327 operation,
1328 path_params: HashMap::new(),
1329 query_params: HashMap::new(),
1330 headers: HashMap::new(),
1331 body: None,
1332 };
1333
1334 let config = K6Config {
1335 target_url: "https://api.example.com".to_string(),
1336 base_path: None,
1337 scenario: LoadScenario::Constant,
1338 duration_secs: 30,
1339 max_vus: 5,
1340 threshold_percentile: "p(95)".to_string(),
1341 threshold_ms: 500,
1342 max_error_rate: 0.05,
1343 auth_header: None,
1344 custom_headers: HashMap::new(),
1345 skip_tls_verify: true,
1346 security_testing_enabled: false,
1347 chunked_request_bodies: false,
1348 target_rps: None,
1349 no_keep_alive: false,
1350 geo_source_ips: Vec::new(),
1351 geo_source_headers: Vec::new(),
1352 };
1353
1354 let generator = K6ScriptGenerator::new(config, vec![template]);
1355 let script = generator.generate().expect("Should generate script");
1356
1357 assert!(
1359 script.contains("insecureSkipTLSVerify: true"),
1360 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_no_skip_tls_verify() {
1366 use crate::spec_parser::ApiOperation;
1367 use openapiv3::Operation;
1368
1369 let operation = ApiOperation {
1371 method: "get".to_string(),
1372 path: "/api/users".to_string(),
1373 operation: Operation::default(),
1374 operation_id: Some("getUsers".to_string()),
1375 };
1376
1377 let template = RequestTemplate {
1378 operation,
1379 path_params: HashMap::new(),
1380 query_params: HashMap::new(),
1381 headers: HashMap::new(),
1382 body: None,
1383 };
1384
1385 let config = K6Config {
1386 target_url: "https://api.example.com".to_string(),
1387 base_path: None,
1388 scenario: LoadScenario::Constant,
1389 duration_secs: 30,
1390 max_vus: 5,
1391 threshold_percentile: "p(95)".to_string(),
1392 threshold_ms: 500,
1393 max_error_rate: 0.05,
1394 auth_header: None,
1395 custom_headers: HashMap::new(),
1396 skip_tls_verify: false,
1397 security_testing_enabled: false,
1398 chunked_request_bodies: false,
1399 target_rps: None,
1400 no_keep_alive: false,
1401 geo_source_ips: Vec::new(),
1402 geo_source_headers: Vec::new(),
1403 };
1404
1405 let generator = K6ScriptGenerator::new(config, vec![template]);
1406 let script = generator.generate().expect("Should generate script");
1407
1408 assert!(
1410 !script.contains("insecureSkipTLSVerify"),
1411 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_skip_tls_verify_multiple_operations() {
1417 use crate::spec_parser::ApiOperation;
1418 use openapiv3::Operation;
1419 use serde_json::json;
1420
1421 let operation1 = ApiOperation {
1423 method: "get".to_string(),
1424 path: "/api/users".to_string(),
1425 operation: Operation::default(),
1426 operation_id: Some("getUsers".to_string()),
1427 };
1428
1429 let operation2 = ApiOperation {
1430 method: "post".to_string(),
1431 path: "/api/users".to_string(),
1432 operation: Operation::default(),
1433 operation_id: Some("createUser".to_string()),
1434 };
1435
1436 let template1 = RequestTemplate {
1437 operation: operation1,
1438 path_params: HashMap::new(),
1439 query_params: HashMap::new(),
1440 headers: HashMap::new(),
1441 body: None,
1442 };
1443
1444 let template2 = RequestTemplate {
1445 operation: operation2,
1446 path_params: HashMap::new(),
1447 query_params: HashMap::new(),
1448 headers: HashMap::new(),
1449 body: Some(json!({"name": "test"})),
1450 };
1451
1452 let config = K6Config {
1453 target_url: "https://api.example.com".to_string(),
1454 base_path: None,
1455 scenario: LoadScenario::Constant,
1456 duration_secs: 30,
1457 max_vus: 5,
1458 threshold_percentile: "p(95)".to_string(),
1459 threshold_ms: 500,
1460 max_error_rate: 0.05,
1461 auth_header: None,
1462 custom_headers: HashMap::new(),
1463 skip_tls_verify: true,
1464 security_testing_enabled: false,
1465 chunked_request_bodies: false,
1466 target_rps: None,
1467 no_keep_alive: false,
1468 geo_source_ips: Vec::new(),
1469 geo_source_headers: Vec::new(),
1470 };
1471
1472 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
1473 let script = generator.generate().expect("Should generate script");
1474
1475 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
1478 assert_eq!(
1479 skip_count, 1,
1480 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
1481 );
1482
1483 let options_start = script.find("export const options = {").expect("Should have options");
1485 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
1486 let options_prefix = &script[options_start..scenarios_start];
1487 assert!(
1488 options_prefix.contains("insecureSkipTLSVerify: true"),
1489 "insecureSkipTLSVerify should be in global options block"
1490 );
1491 }
1492
1493 #[test]
1494 fn test_dynamic_params_in_body() {
1495 use crate::spec_parser::ApiOperation;
1496 use openapiv3::Operation;
1497 use serde_json::json;
1498
1499 let operation = ApiOperation {
1501 method: "post".to_string(),
1502 path: "/api/resources".to_string(),
1503 operation: Operation::default(),
1504 operation_id: Some("createResource".to_string()),
1505 };
1506
1507 let template = RequestTemplate {
1508 operation,
1509 path_params: HashMap::new(),
1510 query_params: HashMap::new(),
1511 headers: HashMap::new(),
1512 body: Some(json!({
1513 "name": "load-test-${__VU}",
1514 "iteration": "${__ITER}"
1515 })),
1516 };
1517
1518 let config = K6Config {
1519 target_url: "https://api.example.com".to_string(),
1520 base_path: None,
1521 scenario: LoadScenario::Constant,
1522 duration_secs: 30,
1523 max_vus: 5,
1524 threshold_percentile: "p(95)".to_string(),
1525 threshold_ms: 500,
1526 max_error_rate: 0.05,
1527 auth_header: None,
1528 custom_headers: HashMap::new(),
1529 skip_tls_verify: false,
1530 security_testing_enabled: false,
1531 chunked_request_bodies: false,
1532 target_rps: None,
1533 no_keep_alive: false,
1534 geo_source_ips: Vec::new(),
1535 geo_source_headers: Vec::new(),
1536 };
1537
1538 let generator = K6ScriptGenerator::new(config, vec![template]);
1539 let script = generator.generate().expect("Should generate script");
1540
1541 assert!(
1543 script.contains("Dynamic body with runtime placeholders"),
1544 "Script should contain comment about dynamic body"
1545 );
1546
1547 assert!(
1549 script.contains("__VU"),
1550 "Script should contain __VU reference for dynamic VU-based values"
1551 );
1552
1553 assert!(
1555 script.contains("__ITER"),
1556 "Script should contain __ITER reference for dynamic iteration values"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_dynamic_params_with_uuid() {
1562 use crate::spec_parser::ApiOperation;
1563 use openapiv3::Operation;
1564 use serde_json::json;
1565
1566 let operation = ApiOperation {
1568 method: "post".to_string(),
1569 path: "/api/resources".to_string(),
1570 operation: Operation::default(),
1571 operation_id: Some("createResource".to_string()),
1572 };
1573
1574 let template = RequestTemplate {
1575 operation,
1576 path_params: HashMap::new(),
1577 query_params: HashMap::new(),
1578 headers: HashMap::new(),
1579 body: Some(json!({
1580 "id": "${__UUID}"
1581 })),
1582 };
1583
1584 let config = K6Config {
1585 target_url: "https://api.example.com".to_string(),
1586 base_path: None,
1587 scenario: LoadScenario::Constant,
1588 duration_secs: 30,
1589 max_vus: 5,
1590 threshold_percentile: "p(95)".to_string(),
1591 threshold_ms: 500,
1592 max_error_rate: 0.05,
1593 auth_header: None,
1594 custom_headers: HashMap::new(),
1595 skip_tls_verify: false,
1596 security_testing_enabled: false,
1597 chunked_request_bodies: false,
1598 target_rps: None,
1599 no_keep_alive: false,
1600 geo_source_ips: Vec::new(),
1601 geo_source_headers: Vec::new(),
1602 };
1603
1604 let generator = K6ScriptGenerator::new(config, vec![template]);
1605 let script = generator.generate().expect("Should generate script");
1606
1607 assert!(
1610 !script.contains("k6/experimental/webcrypto"),
1611 "Script should NOT include deprecated k6/experimental/webcrypto import"
1612 );
1613
1614 assert!(
1616 script.contains("crypto.randomUUID()"),
1617 "Script should contain crypto.randomUUID() for UUID placeholder"
1618 );
1619 }
1620
1621 #[test]
1622 fn test_dynamic_params_with_counter() {
1623 use crate::spec_parser::ApiOperation;
1624 use openapiv3::Operation;
1625 use serde_json::json;
1626
1627 let operation = ApiOperation {
1629 method: "post".to_string(),
1630 path: "/api/resources".to_string(),
1631 operation: Operation::default(),
1632 operation_id: Some("createResource".to_string()),
1633 };
1634
1635 let template = RequestTemplate {
1636 operation,
1637 path_params: HashMap::new(),
1638 query_params: HashMap::new(),
1639 headers: HashMap::new(),
1640 body: Some(json!({
1641 "sequence": "${__COUNTER}"
1642 })),
1643 };
1644
1645 let config = K6Config {
1646 target_url: "https://api.example.com".to_string(),
1647 base_path: None,
1648 scenario: LoadScenario::Constant,
1649 duration_secs: 30,
1650 max_vus: 5,
1651 threshold_percentile: "p(95)".to_string(),
1652 threshold_ms: 500,
1653 max_error_rate: 0.05,
1654 auth_header: None,
1655 custom_headers: HashMap::new(),
1656 skip_tls_verify: false,
1657 security_testing_enabled: false,
1658 chunked_request_bodies: false,
1659 target_rps: None,
1660 no_keep_alive: false,
1661 geo_source_ips: Vec::new(),
1662 geo_source_headers: Vec::new(),
1663 };
1664
1665 let generator = K6ScriptGenerator::new(config, vec![template]);
1666 let script = generator.generate().expect("Should generate script");
1667
1668 assert!(
1670 script.contains("let globalCounter = 0"),
1671 "Script should include globalCounter initialization when COUNTER placeholder is used"
1672 );
1673
1674 assert!(
1676 script.contains("globalCounter++"),
1677 "Script should contain globalCounter++ for COUNTER placeholder"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_static_body_no_dynamic_marker() {
1683 use crate::spec_parser::ApiOperation;
1684 use openapiv3::Operation;
1685 use serde_json::json;
1686
1687 let operation = ApiOperation {
1689 method: "post".to_string(),
1690 path: "/api/resources".to_string(),
1691 operation: Operation::default(),
1692 operation_id: Some("createResource".to_string()),
1693 };
1694
1695 let template = RequestTemplate {
1696 operation,
1697 path_params: HashMap::new(),
1698 query_params: HashMap::new(),
1699 headers: HashMap::new(),
1700 body: Some(json!({
1701 "name": "static-value",
1702 "count": 42
1703 })),
1704 };
1705
1706 let config = K6Config {
1707 target_url: "https://api.example.com".to_string(),
1708 base_path: None,
1709 scenario: LoadScenario::Constant,
1710 duration_secs: 30,
1711 max_vus: 5,
1712 threshold_percentile: "p(95)".to_string(),
1713 threshold_ms: 500,
1714 max_error_rate: 0.05,
1715 auth_header: None,
1716 custom_headers: HashMap::new(),
1717 skip_tls_verify: false,
1718 security_testing_enabled: false,
1719 chunked_request_bodies: false,
1720 target_rps: None,
1721 no_keep_alive: false,
1722 geo_source_ips: Vec::new(),
1723 geo_source_headers: Vec::new(),
1724 };
1725
1726 let generator = K6ScriptGenerator::new(config, vec![template]);
1727 let script = generator.generate().expect("Should generate script");
1728
1729 assert!(
1731 !script.contains("Dynamic body with runtime placeholders"),
1732 "Script should NOT contain dynamic body comment for static body"
1733 );
1734
1735 assert!(
1737 !script.contains("webcrypto"),
1738 "Script should NOT include webcrypto import for static body"
1739 );
1740
1741 assert!(
1743 !script.contains("let globalCounter"),
1744 "Script should NOT include globalCounter for static body"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_security_testing_enabled_generates_calling_code() {
1750 use crate::spec_parser::ApiOperation;
1751 use openapiv3::Operation;
1752 use serde_json::json;
1753
1754 let operation = ApiOperation {
1755 method: "post".to_string(),
1756 path: "/api/users".to_string(),
1757 operation: Operation::default(),
1758 operation_id: Some("createUser".to_string()),
1759 };
1760
1761 let template = RequestTemplate {
1762 operation,
1763 path_params: HashMap::new(),
1764 query_params: HashMap::new(),
1765 headers: HashMap::new(),
1766 body: Some(json!({"name": "test"})),
1767 };
1768
1769 let config = K6Config {
1770 target_url: "https://api.example.com".to_string(),
1771 base_path: None,
1772 scenario: LoadScenario::Constant,
1773 duration_secs: 30,
1774 max_vus: 5,
1775 threshold_percentile: "p(95)".to_string(),
1776 threshold_ms: 500,
1777 max_error_rate: 0.05,
1778 auth_header: None,
1779 custom_headers: HashMap::new(),
1780 skip_tls_verify: false,
1781 security_testing_enabled: true,
1782 chunked_request_bodies: false,
1783 target_rps: None,
1784 no_keep_alive: false,
1785 geo_source_ips: Vec::new(),
1786 geo_source_headers: Vec::new(),
1787 };
1788
1789 let generator = K6ScriptGenerator::new(config, vec![template]);
1790 let script = generator.generate().expect("Should generate script");
1791
1792 assert!(
1794 script.contains("getNextSecurityPayload"),
1795 "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
1796 );
1797 assert!(
1798 script.contains("applySecurityPayload"),
1799 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
1800 );
1801 assert!(
1802 script.contains("secPayloadGroup"),
1803 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
1804 );
1805 assert!(
1806 script.contains("secBodyPayload"),
1807 "Script should contain secBodyPayload variable when security_testing_enabled is true"
1808 );
1809 assert!(
1811 script.contains("hasSecCookie"),
1812 "Script should track hasSecCookie for CookieJar conflict avoidance"
1813 );
1814 assert!(
1815 script.contains("secRequestOpts"),
1816 "Script should use secRequestOpts to conditionally skip CookieJar"
1817 );
1818 assert!(
1820 script.contains("const requestHeaders = { ..."),
1821 "Script should spread headers into mutable copy for security payload injection"
1822 );
1823 assert!(
1825 script.contains("secPayload.injectAsPath"),
1826 "Script should check injectAsPath for path-based URI injection"
1827 );
1828 assert!(
1830 script.contains("secBodyPayload.formBody"),
1831 "Script should check formBody for form-encoded body delivery"
1832 );
1833 assert!(
1834 script.contains("application/x-www-form-urlencoded"),
1835 "Script should set Content-Type for form-encoded body"
1836 );
1837 let op_comment_pos =
1839 script.find("// Operation 0:").expect("Should have Operation 0 comment");
1840 let sec_payload_pos = script
1841 .find("const secPayloadGroup = typeof getNextSecurityPayload")
1842 .expect("Should have secPayloadGroup assignment");
1843 assert!(
1844 sec_payload_pos > op_comment_pos,
1845 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
1846 );
1847 }
1848
1849 #[test]
1850 fn test_security_testing_disabled_no_calling_code() {
1851 use crate::spec_parser::ApiOperation;
1852 use openapiv3::Operation;
1853 use serde_json::json;
1854
1855 let operation = ApiOperation {
1856 method: "post".to_string(),
1857 path: "/api/users".to_string(),
1858 operation: Operation::default(),
1859 operation_id: Some("createUser".to_string()),
1860 };
1861
1862 let template = RequestTemplate {
1863 operation,
1864 path_params: HashMap::new(),
1865 query_params: HashMap::new(),
1866 headers: HashMap::new(),
1867 body: Some(json!({"name": "test"})),
1868 };
1869
1870 let config = K6Config {
1871 target_url: "https://api.example.com".to_string(),
1872 base_path: None,
1873 scenario: LoadScenario::Constant,
1874 duration_secs: 30,
1875 max_vus: 5,
1876 threshold_percentile: "p(95)".to_string(),
1877 threshold_ms: 500,
1878 max_error_rate: 0.05,
1879 auth_header: None,
1880 custom_headers: HashMap::new(),
1881 skip_tls_verify: false,
1882 security_testing_enabled: false,
1883 chunked_request_bodies: false,
1884 target_rps: None,
1885 no_keep_alive: false,
1886 geo_source_ips: Vec::new(),
1887 geo_source_headers: Vec::new(),
1888 };
1889
1890 let generator = K6ScriptGenerator::new(config, vec![template]);
1891 let script = generator.generate().expect("Should generate script");
1892
1893 assert!(
1895 !script.contains("getNextSecurityPayload"),
1896 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1897 );
1898 assert!(
1899 !script.contains("applySecurityPayload"),
1900 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1901 );
1902 assert!(
1903 !script.contains("secPayloadGroup"),
1904 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
1905 );
1906 assert!(
1907 !script.contains("secBodyPayload"),
1908 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
1909 );
1910 assert!(
1911 !script.contains("hasSecCookie"),
1912 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
1913 );
1914 assert!(
1915 !script.contains("secRequestOpts"),
1916 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
1917 );
1918 assert!(
1919 !script.contains("injectAsPath"),
1920 "Script should NOT contain injectAsPath when security_testing_enabled is false"
1921 );
1922 assert!(
1923 !script.contains("formBody"),
1924 "Script should NOT contain formBody when security_testing_enabled is false"
1925 );
1926 }
1927
1928 #[test]
1932 fn test_security_e2e_definitions_and_calls_both_present() {
1933 use crate::security_payloads::{
1934 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1935 };
1936 use crate::spec_parser::ApiOperation;
1937 use openapiv3::Operation;
1938 use serde_json::json;
1939
1940 let operation = ApiOperation {
1942 method: "post".to_string(),
1943 path: "/api/users".to_string(),
1944 operation: Operation::default(),
1945 operation_id: Some("createUser".to_string()),
1946 };
1947
1948 let template = RequestTemplate {
1949 operation,
1950 path_params: HashMap::new(),
1951 query_params: HashMap::new(),
1952 headers: HashMap::new(),
1953 body: Some(json!({"name": "test"})),
1954 };
1955
1956 let config = K6Config {
1957 target_url: "https://api.example.com".to_string(),
1958 base_path: None,
1959 scenario: LoadScenario::Constant,
1960 duration_secs: 30,
1961 max_vus: 5,
1962 threshold_percentile: "p(95)".to_string(),
1963 threshold_ms: 500,
1964 max_error_rate: 0.05,
1965 auth_header: None,
1966 custom_headers: HashMap::new(),
1967 skip_tls_verify: false,
1968 security_testing_enabled: true,
1969 chunked_request_bodies: false,
1970 target_rps: None,
1971 no_keep_alive: false,
1972 geo_source_ips: Vec::new(),
1973 geo_source_headers: Vec::new(),
1974 };
1975
1976 let generator = K6ScriptGenerator::new(config, vec![template]);
1977 let mut script = generator.generate().expect("Should generate base script");
1978
1979 let security_config = SecurityTestConfig::default().enable();
1981 let payloads = SecurityPayloads::get_payloads(&security_config);
1982 assert!(!payloads.is_empty(), "Should have built-in payloads");
1983
1984 let mut additional_code = String::new();
1985 additional_code
1986 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1987 additional_code.push('\n');
1988 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1989 additional_code.push('\n');
1990
1991 if let Some(pos) = script.find("export const options") {
1993 script.insert_str(
1994 pos,
1995 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1996 );
1997 }
1998
1999 assert!(
2002 script.contains("function getNextSecurityPayload()"),
2003 "Final script must contain getNextSecurityPayload function DEFINITION"
2004 );
2005 assert!(
2006 script.contains("function applySecurityPayload("),
2007 "Final script must contain applySecurityPayload function DEFINITION"
2008 );
2009 assert!(
2010 script.contains("securityPayloads"),
2011 "Final script must contain securityPayloads array"
2012 );
2013
2014 assert!(
2016 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2017 "Final script must contain secPayloadGroup assignment (template calling code)"
2018 );
2019 assert!(
2020 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2021 "Final script must contain applySecurityPayload CALL with secBodyPayload"
2022 );
2023 assert!(
2024 script.contains("const requestHeaders = { ..."),
2025 "Final script must spread headers for security payload header injection"
2026 );
2027 assert!(
2028 script.contains("for (const secPayload of secPayloadGroup)"),
2029 "Final script must loop over secPayloadGroup"
2030 );
2031 assert!(
2032 script.contains("secPayload.injectAsPath"),
2033 "Final script must check injectAsPath for path-based URI injection"
2034 );
2035 assert!(
2036 script.contains("secBodyPayload.formBody"),
2037 "Final script must check formBody for form-encoded body delivery"
2038 );
2039
2040 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2042 let call_pos =
2043 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2044 let options_pos = script.find("export const options").unwrap();
2045 let default_fn_pos = script.find("export default function").unwrap();
2046
2047 assert!(
2048 def_pos < options_pos,
2049 "Function definitions must appear before export const options"
2050 );
2051 assert!(
2052 call_pos > default_fn_pos,
2053 "Calling code must appear inside export default function"
2054 );
2055 }
2056
2057 #[test]
2059 fn test_security_uri_injection_for_get_requests() {
2060 use crate::spec_parser::ApiOperation;
2061 use openapiv3::Operation;
2062
2063 let operation = ApiOperation {
2064 method: "get".to_string(),
2065 path: "/api/users".to_string(),
2066 operation: Operation::default(),
2067 operation_id: Some("listUsers".to_string()),
2068 };
2069
2070 let template = RequestTemplate {
2071 operation,
2072 path_params: HashMap::new(),
2073 query_params: HashMap::new(),
2074 headers: HashMap::new(),
2075 body: None,
2076 };
2077
2078 let config = K6Config {
2079 target_url: "https://api.example.com".to_string(),
2080 base_path: None,
2081 scenario: LoadScenario::Constant,
2082 duration_secs: 30,
2083 max_vus: 5,
2084 threshold_percentile: "p(95)".to_string(),
2085 threshold_ms: 500,
2086 max_error_rate: 0.05,
2087 auth_header: None,
2088 custom_headers: HashMap::new(),
2089 skip_tls_verify: false,
2090 security_testing_enabled: true,
2091 chunked_request_bodies: false,
2092 target_rps: None,
2093 no_keep_alive: false,
2094 geo_source_ips: Vec::new(),
2095 geo_source_headers: Vec::new(),
2096 };
2097
2098 let generator = K6ScriptGenerator::new(config, vec![template]);
2099 let script = generator.generate().expect("Should generate script");
2100
2101 assert!(
2103 script.contains("requestUrl"),
2104 "Script should build requestUrl variable for URI payload injection"
2105 );
2106 assert!(
2107 script.contains("secPayload.location === 'uri'"),
2108 "Script should check for URI-location payloads"
2109 );
2110 assert!(
2112 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2113 "Script should URL-encode security payload in query string for valid HTTP"
2114 );
2115 assert!(
2117 script.contains("secPayload.injectAsPath"),
2118 "Script should check injectAsPath for path-based URI injection"
2119 );
2120 assert!(
2121 script.contains("encodeURI(secPayload.payload)"),
2122 "Script should use encodeURI for path-based injection"
2123 );
2124 assert!(
2126 script.contains("http.get(requestUrl,"),
2127 "GET request should use requestUrl (with URI injection) instead of inline URL"
2128 );
2129 }
2130
2131 #[test]
2133 fn test_security_uri_injection_for_post_requests() {
2134 use crate::spec_parser::ApiOperation;
2135 use openapiv3::Operation;
2136 use serde_json::json;
2137
2138 let operation = ApiOperation {
2139 method: "post".to_string(),
2140 path: "/api/users".to_string(),
2141 operation: Operation::default(),
2142 operation_id: Some("createUser".to_string()),
2143 };
2144
2145 let template = RequestTemplate {
2146 operation,
2147 path_params: HashMap::new(),
2148 query_params: HashMap::new(),
2149 headers: HashMap::new(),
2150 body: Some(json!({"name": "test"})),
2151 };
2152
2153 let config = K6Config {
2154 target_url: "https://api.example.com".to_string(),
2155 base_path: None,
2156 scenario: LoadScenario::Constant,
2157 duration_secs: 30,
2158 max_vus: 5,
2159 threshold_percentile: "p(95)".to_string(),
2160 threshold_ms: 500,
2161 max_error_rate: 0.05,
2162 auth_header: None,
2163 custom_headers: HashMap::new(),
2164 skip_tls_verify: false,
2165 security_testing_enabled: true,
2166 chunked_request_bodies: false,
2167 target_rps: None,
2168 no_keep_alive: false,
2169 geo_source_ips: Vec::new(),
2170 geo_source_headers: Vec::new(),
2171 };
2172
2173 let generator = K6ScriptGenerator::new(config, vec![template]);
2174 let script = generator.generate().expect("Should generate script");
2175
2176 assert!(
2178 script.contains("requestUrl"),
2179 "POST script should build requestUrl for URI payload injection"
2180 );
2181 assert!(
2182 script.contains("secPayload.location === 'uri'"),
2183 "POST script should check for URI-location payloads"
2184 );
2185 assert!(
2186 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2187 "POST script should apply security body payload to request body"
2188 );
2189 assert!(
2191 script.contains("http.post(requestUrl,"),
2192 "POST request should use requestUrl (with URI injection) instead of inline URL"
2193 );
2194 }
2195
2196 #[test]
2198 fn test_no_uri_injection_when_security_disabled() {
2199 use crate::spec_parser::ApiOperation;
2200 use openapiv3::Operation;
2201
2202 let operation = ApiOperation {
2203 method: "get".to_string(),
2204 path: "/api/users".to_string(),
2205 operation: Operation::default(),
2206 operation_id: Some("listUsers".to_string()),
2207 };
2208
2209 let template = RequestTemplate {
2210 operation,
2211 path_params: HashMap::new(),
2212 query_params: HashMap::new(),
2213 headers: HashMap::new(),
2214 body: None,
2215 };
2216
2217 let config = K6Config {
2218 target_url: "https://api.example.com".to_string(),
2219 base_path: None,
2220 scenario: LoadScenario::Constant,
2221 duration_secs: 30,
2222 max_vus: 5,
2223 threshold_percentile: "p(95)".to_string(),
2224 threshold_ms: 500,
2225 max_error_rate: 0.05,
2226 auth_header: None,
2227 custom_headers: HashMap::new(),
2228 skip_tls_verify: false,
2229 security_testing_enabled: false,
2230 chunked_request_bodies: false,
2231 target_rps: None,
2232 no_keep_alive: false,
2233 geo_source_ips: Vec::new(),
2234 geo_source_headers: Vec::new(),
2235 };
2236
2237 let generator = K6ScriptGenerator::new(config, vec![template]);
2238 let script = generator.generate().expect("Should generate script");
2239
2240 assert!(
2242 !script.contains("requestUrl"),
2243 "Script should NOT have requestUrl when security is disabled"
2244 );
2245 assert!(
2246 !script.contains("secPayloadGroup"),
2247 "Script should NOT have secPayloadGroup when security is disabled"
2248 );
2249 assert!(
2250 !script.contains("secBodyPayload"),
2251 "Script should NOT have secBodyPayload when security is disabled"
2252 );
2253 }
2254
2255 #[test]
2257 fn test_uses_per_request_cookie_jar() {
2258 use crate::spec_parser::ApiOperation;
2259 use openapiv3::Operation;
2260
2261 let operation = ApiOperation {
2262 method: "get".to_string(),
2263 path: "/api/users".to_string(),
2264 operation: Operation::default(),
2265 operation_id: Some("listUsers".to_string()),
2266 };
2267
2268 let template = RequestTemplate {
2269 operation,
2270 path_params: HashMap::new(),
2271 query_params: HashMap::new(),
2272 headers: HashMap::new(),
2273 body: None,
2274 };
2275
2276 let config = K6Config {
2277 target_url: "https://api.example.com".to_string(),
2278 base_path: None,
2279 scenario: LoadScenario::Constant,
2280 duration_secs: 30,
2281 max_vus: 5,
2282 threshold_percentile: "p(95)".to_string(),
2283 threshold_ms: 500,
2284 max_error_rate: 0.05,
2285 auth_header: None,
2286 custom_headers: HashMap::new(),
2287 skip_tls_verify: false,
2288 security_testing_enabled: false,
2289 chunked_request_bodies: false,
2290 target_rps: None,
2291 no_keep_alive: false,
2292 geo_source_ips: Vec::new(),
2293 geo_source_headers: Vec::new(),
2294 };
2295
2296 let generator = K6ScriptGenerator::new(config, vec![template]);
2297 let script = generator.generate().expect("Should generate script");
2298
2299 assert!(
2301 script.contains("jar: new http.CookieJar()"),
2302 "Script should create fresh CookieJar per request"
2303 );
2304 assert!(
2305 !script.contains("jar: null"),
2306 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2307 );
2308 assert!(
2309 !script.contains("EMPTY_JAR"),
2310 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2311 );
2312 }
2313}