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