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