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