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