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 fn uniquify_name(base: String, used: &mut HashSet<String>) -> String {
297 if used.insert(base.clone()) {
298 return base;
299 }
300 let mut n = 2u32;
301 loop {
302 let candidate = format!("{base}_{n}");
303 if used.insert(candidate.clone()) {
304 return candidate;
305 }
306 n = n.saturating_add(1);
307 if n == u32::MAX {
308 use std::collections::hash_map::DefaultHasher;
309 use std::hash::{Hash, Hasher};
310 let mut hasher = DefaultHasher::new();
311 base.hash(&mut hasher);
312 used.len().hash(&mut hasher);
313 let fallback = format!("{base}_{:08x}", hasher.finish() as u32);
314 used.insert(fallback.clone());
315 return fallback;
316 }
317 }
318 }
319
320 pub fn sanitize_js_identifier(name: &str) -> String {
330 let mut result = String::new();
331 let mut chars = name.chars().peekable();
332
333 if let Some(&first) = chars.peek() {
335 if first.is_ascii_digit() {
336 result.push('_');
337 }
338 }
339
340 for ch in chars {
341 if ch.is_ascii_alphanumeric() || ch == '_' {
342 result.push(ch);
343 } else {
344 if !result.ends_with('_') {
347 result.push('_');
348 }
349 }
350 }
351
352 result = result.trim_end_matches('_').to_string();
354
355 if result.is_empty() {
357 result = "operation".to_string();
358 }
359
360 result
361 }
362
363 fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
365 let stages = self
366 .config
367 .scenario
368 .generate_stages(self.config.duration_secs, self.config.max_vus);
369
370 let base_path = self.config.base_path.as_deref().unwrap_or("");
372
373 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
375 let mut used_js_names: HashSet<String> = HashSet::new();
378 let mut used_metric_names: HashSet<String> = HashSet::new();
379
380 let mut operations = Vec::with_capacity(self.templates.len());
381 for (idx, template) in self.templates.iter().enumerate() {
382 let display_name = template.operation.display_name();
383 let sanitized_name = Self::uniquify_name(
384 Self::sanitize_js_identifier(&display_name),
385 &mut used_js_names,
386 );
387 let metric_name = Self::uniquify_name(
395 Self::sanitize_k6_metric_name(&display_name),
396 &mut used_metric_names,
397 );
398 let k6_method = match template.operation.method.to_lowercase().as_str() {
400 "delete" => "del".to_string(),
401 m => m.to_string(),
402 };
403 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
406
407 let raw_path = template.generate_path();
410 let full_path = join_base_path(base_path, &raw_path);
411 let processed_path = DynamicParamProcessor::process_path(&full_path);
412 all_placeholders.extend(processed_path.placeholders.clone());
413
414 let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
416 let processed_body = DynamicParamProcessor::process_json_body(body);
417 all_placeholders.extend(processed_body.placeholders.clone());
418 (Some(processed_body.value), processed_body.is_dynamic)
419 } else {
420 (None, false)
421 };
422
423 let path_value = if processed_path.is_dynamic {
433 processed_path.value
434 } else {
435 serde_json::to_string(&full_path).unwrap_or_else(|_| "\"/\"".to_string())
436 };
437
438 operations.push(K6OperationData {
439 index: idx,
440 name: sanitized_name,
441 metric_name,
442 display_name,
443 method: k6_method,
444 path: Value::String(path_value),
445 path_is_dynamic: processed_path.is_dynamic,
446 headers: Value::String(self.build_headers_json(template)),
447 body: body_value.map(Value::String),
448 body_is_dynamic,
449 has_body: template.body.is_some(),
450 is_get_or_head,
451 });
452 }
453
454 let required_imports: Vec<String> =
456 DynamicParamProcessor::get_required_imports(&all_placeholders)
457 .into_iter()
458 .map(String::from)
459 .collect();
460 let required_globals: Vec<String> =
461 DynamicParamProcessor::get_required_globals(&all_placeholders)
462 .into_iter()
463 .map(String::from)
464 .collect();
465 let has_dynamic_values = !all_placeholders.is_empty();
466
467 Ok(K6ScriptTemplateData {
468 base_url: self.config.target_url.clone(),
469 stages: stages
470 .iter()
471 .map(|s| K6StageData {
472 duration: s.duration.clone(),
473 target: s.target,
474 })
475 .collect(),
476 operations,
477 threshold_percentile: self.config.threshold_percentile.clone(),
478 threshold_ms: self.config.threshold_ms,
479 max_error_rate: self.config.max_error_rate,
480 abort_on_error: self.abort_on_error,
481 abort_on_error_rate: self.abort_on_error_rate,
482 scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
483 skip_tls_verify: self.config.skip_tls_verify,
484 has_dynamic_values,
485 dynamic_imports: required_imports,
486 dynamic_globals: required_globals,
487 security_testing_enabled: self.config.security_testing_enabled,
488 has_custom_headers: !self.config.custom_headers.is_empty(),
489 chunked_request_bodies: self.config.chunked_request_bodies,
490 target_rps: self.config.target_rps,
491 no_keep_alive: self.config.no_keep_alive,
492 duration_secs: self.config.duration_secs,
493 max_vus: self.config.max_vus,
494 start_vus: match self.config.scenario {
498 LoadScenario::Constant => self.config.max_vus,
499 _ => 0,
500 },
501 geo_source_ips: self.config.geo_source_ips.clone(),
509 geo_source_headers: self.config.geo_source_headers.clone(),
510 has_geo_source: !self.config.geo_source_ips.is_empty()
511 && !self.config.geo_source_headers.is_empty(),
512 geo_source_ips_json: serde_json::to_string(&self.config.geo_source_ips)
513 .unwrap_or_else(|_| "[]".to_string()),
514 geo_source_headers_json: serde_json::to_string(&self.config.geo_source_headers)
515 .unwrap_or_else(|_| "[]".to_string()),
516 })
517 }
518
519 fn build_headers_json(&self, template: &RequestTemplate) -> String {
521 let mut headers = template.get_headers();
522
523 if let Some(auth) = &self.config.auth_header {
525 headers.insert("Authorization".to_string(), auth.clone());
526 }
527
528 for (key, value) in &self.config.custom_headers {
530 headers.insert(key.clone(), value.clone());
531 }
532
533 if self.config.chunked_request_bodies && template.body.is_some() {
538 headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
539 }
540
541 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
543 }
544
545 pub fn validate_script(script: &str) -> Vec<String> {
554 let mut errors = Vec::new();
555
556 if !script.contains("import http from 'k6/http'") {
558 errors.push("Missing required import: 'k6/http'".to_string());
559 }
560 if !script.contains("import { check") && !script.contains("import {check") {
561 errors.push("Missing required import: 'check' from 'k6'".to_string());
562 }
563 if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
564 errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
565 }
566
567 let lines: Vec<&str> = script.lines().collect();
571 let mut seen_metric_consts: HashSet<String> = HashSet::new();
572 for (line_num, line) in lines.iter().enumerate() {
573 let trimmed = line.trim();
574
575 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
577 if let Some(name) = trimmed
581 .strip_prefix("const ")
582 .and_then(|rest| rest.split('=').next())
583 .map(str::trim)
584 .filter(|n| {
585 !n.is_empty() && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
586 })
587 {
588 if !seen_metric_consts.insert(name.to_string()) {
589 errors.push(format!(
590 "Line {}: duplicate const '{name}'. k6 exits 107 (ScriptException) when two traffic cases sanitize to the same identifier.",
591 line_num + 1
592 ));
593 }
594 }
595 if let Some(start) = trimmed.find('\'') {
598 if let Some(end) = trimmed[start + 1..].find('\'') {
599 let metric_name = &trimmed[start + 1..start + 1 + end];
600 if !Self::is_valid_k6_metric_name(metric_name) {
601 errors.push(format!(
602 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
603 line_num + 1,
604 metric_name
605 ));
606 }
607 }
608 } else if let Some(start) = trimmed.find('"') {
609 if let Some(end) = trimmed[start + 1..].find('"') {
610 let metric_name = &trimmed[start + 1..start + 1 + end];
611 if !Self::is_valid_k6_metric_name(metric_name) {
612 errors.push(format!(
613 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
614 line_num + 1,
615 metric_name
616 ));
617 }
618 }
619 }
620 }
621
622 if !trimmed.starts_with("//") {
629 if let Some(col) = Self::invalid_js_hex_escape_column(trimmed) {
630 errors.push(format!(
631 "Line {}:{}: invalid JS hex escape \\x (k6 requires two hex digits). Static paths must be JSON-encoded, not dumped into a template literal.",
632 line_num + 1,
633 col + 1
634 ));
635 }
636 }
637
638 if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
640 if let Some(equals_pos) = trimmed.find('=') {
641 let var_decl = &trimmed[..equals_pos];
642 if var_decl.contains('.')
645 && !var_decl.contains("'")
646 && !var_decl.contains("\"")
647 && !var_decl.trim().starts_with("//")
648 {
649 errors.push(format!(
650 "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
651 line_num + 1,
652 var_decl.trim()
653 ));
654 }
655 }
656 }
657 }
658
659 errors
660 }
661
662 fn invalid_js_hex_escape_column(line: &str) -> Option<usize> {
669 let bytes = line.as_bytes();
670 let mut i = 0;
671 while i + 1 < bytes.len() {
672 if bytes[i] == b'\\' && bytes[i + 1] == b'x' {
673 let mut preceding = 0usize;
674 let mut j = i;
675 while j > 0 && bytes[j - 1] == b'\\' {
676 preceding += 1;
677 j -= 1;
678 }
679 if preceding.is_multiple_of(2) {
682 let hex_ok = i + 3 < bytes.len()
683 && bytes[i + 2].is_ascii_hexdigit()
684 && bytes[i + 3].is_ascii_hexdigit();
685 if !hex_ok {
686 return Some(i);
687 }
688 }
689 }
690 i += 1;
691 }
692 None
693 }
694
695 fn is_valid_k6_metric_name(name: &str) -> bool {
702 if name.is_empty() || name.len() > 128 {
703 return false;
704 }
705
706 let mut chars = name.chars();
707
708 if let Some(first) = chars.next() {
710 if !first.is_ascii_alphabetic() && first != '_' {
711 return false;
712 }
713 }
714
715 for ch in chars {
717 if !ch.is_ascii_alphanumeric() && ch != '_' {
718 return false;
719 }
720 }
721
722 true
723 }
724}
725
726fn join_base_path(base_path: &str, raw_path: &str) -> String {
732 match base_path {
733 "" | "/" => raw_path.to_string(),
734 bp => {
735 let bp = bp.trim_end_matches('/');
736 if raw_path.starts_with('/') {
737 format!("{}{}", bp, raw_path)
738 } else {
739 format!("{}/{}", bp, raw_path)
740 }
741 }
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn root_base_path_does_not_double_slash() {
751 assert_eq!(
752 join_base_path(
753 "/",
754 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
755 ),
756 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
757 );
758 assert_eq!(join_base_path("", "/pets"), "/pets");
759 assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
760 assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
761 }
762
763 #[test]
764 fn test_k6_config_creation() {
765 let config = K6Config {
766 target_url: "https://api.example.com".to_string(),
767 base_path: None,
768 scenario: LoadScenario::RampUp,
769 duration_secs: 60,
770 max_vus: 10,
771 threshold_percentile: "p(95)".to_string(),
772 threshold_ms: 500,
773 max_error_rate: 0.05,
774 auth_header: None,
775 custom_headers: HashMap::new(),
776 skip_tls_verify: false,
777 security_testing_enabled: false,
778 chunked_request_bodies: false,
779 target_rps: None,
780 no_keep_alive: false,
781 geo_source_ips: Vec::new(),
782 geo_source_headers: Vec::new(),
783 };
784
785 assert_eq!(config.duration_secs, 60);
786 assert_eq!(config.max_vus, 10);
787 }
788
789 #[test]
790 fn test_script_generator_creation() {
791 let config = K6Config {
792 target_url: "https://api.example.com".to_string(),
793 base_path: None,
794 scenario: LoadScenario::Constant,
795 duration_secs: 30,
796 max_vus: 5,
797 threshold_percentile: "p(95)".to_string(),
798 threshold_ms: 500,
799 max_error_rate: 0.05,
800 auth_header: None,
801 custom_headers: HashMap::new(),
802 skip_tls_verify: false,
803 security_testing_enabled: false,
804 chunked_request_bodies: false,
805 target_rps: None,
806 no_keep_alive: false,
807 geo_source_ips: Vec::new(),
808 geo_source_headers: Vec::new(),
809 };
810
811 let templates = vec![];
812 let generator = K6ScriptGenerator::new(config, templates);
813
814 assert_eq!(generator.templates.len(), 0);
815 }
816
817 #[test]
818 fn colliding_operation_titles_get_unique_const_names() {
819 use crate::spec_parser::ApiOperation;
823 use openapiv3::Operation;
824
825 fn tmpl(id: &str, path: &str) -> RequestTemplate {
826 RequestTemplate {
827 operation: ApiOperation {
828 method: "get".to_string(),
829 path: path.to_string(),
830 operation: Operation::default(),
831 operation_id: Some(id.to_string()),
832 },
833 path_params: HashMap::new(),
834 query_params: HashMap::new(),
835 headers: HashMap::new(),
836 body: None,
837 }
838 }
839
840 let config = K6Config {
841 target_url: "https://example.test".to_string(),
842 base_path: None,
843 scenario: LoadScenario::Constant,
844 duration_secs: 5,
845 max_vus: 1,
846 threshold_percentile: "p(95)".to_string(),
847 threshold_ms: 500,
848 max_error_rate: 0.05,
849 auth_header: None,
850 custom_headers: HashMap::new(),
851 skip_tls_verify: false,
852 security_testing_enabled: false,
853 chunked_request_bodies: false,
854 target_rps: None,
855 no_keep_alive: false,
856 geo_source_ips: Vec::new(),
857 geo_source_headers: Vec::new(),
858 };
859 let generator = K6ScriptGenerator::new(
860 config,
861 vec![
862 tmpl("normal request allowed", "/a"),
863 tmpl("normal request allowed", "/b"),
864 ],
865 );
866 let script = generator.generate().expect("script generates");
867 let latency = script
868 .lines()
869 .filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
870 .collect::<Vec<_>>();
871 assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
872 assert!(
873 script.contains("const normal_request_allowed_latency = new Trend"),
874 "first collision keeps the base name"
875 );
876 assert!(
877 script.contains("const normal_request_allowed_2_latency = new Trend")
878 || script.contains("const normal_request_allowed_latency_2 = new Trend"),
879 "second collision must be renamed, script snippet:\n{}",
880 latency.join("\n")
881 );
882 let errors = K6ScriptGenerator::validate_script(&script);
883 assert!(errors.is_empty(), "validate_script: {errors:#?}");
884 }
885
886 #[test]
887 fn werkzeug_unc_backslash_x_is_json_encoded_not_template_literal() {
888 use crate::spec_parser::ApiOperation;
893 use openapiv3::Operation;
894
895 let path = "/static/\\\\attacker.com\\share\\x";
896 let template = RequestTemplate {
897 operation: ApiOperation {
898 method: "get".to_string(),
899 path: path.to_string(),
900 operation: Operation::default(),
901 operation_id: Some("literal UNC double-backslash path blocked".to_string()),
902 },
903 path_params: HashMap::new(),
904 query_params: HashMap::new(),
905 headers: HashMap::new(),
906 body: None,
907 };
908 let config = K6Config {
909 target_url: "https://example.test".to_string(),
910 base_path: None,
911 scenario: LoadScenario::Constant,
912 duration_secs: 5,
913 max_vus: 1,
914 threshold_percentile: "p(95)".to_string(),
915 threshold_ms: 500,
916 max_error_rate: 0.05,
917 auth_header: None,
918 custom_headers: HashMap::new(),
919 skip_tls_verify: false,
920 security_testing_enabled: false,
921 chunked_request_bodies: false,
922 target_rps: None,
923 no_keep_alive: false,
924 geo_source_ips: Vec::new(),
925 geo_source_headers: Vec::new(),
926 };
927 let script = K6ScriptGenerator::new(config, vec![template])
928 .generate()
929 .expect("script generates");
930 let encoded = serde_json::to_string(path).expect("path JSON");
931 assert!(
932 script.contains(&format!("BASE_URL + {encoded}")),
933 "expected BASE_URL + {encoded} in script:\n{script}"
934 );
935 assert!(
936 !script.contains("${BASE_URL}/static/"),
937 "must not dump the raw path into a template literal:\n{script}"
938 );
939 let errors = K6ScriptGenerator::validate_script(&script);
940 assert!(errors.is_empty(), "validate_script: {errors:#?}\n{script}");
941 }
942
943 #[test]
944 fn validate_script_flags_bare_hex_escape_in_template_literal() {
945 let bad = r#"
948import http from 'k6/http';
949import { check, sleep } from 'k6';
950import { Rate, Trend } from 'k6/metrics';
951const t_latency = new Trend('t_latency');
952export default function() {
953 const res = http.get(`${BASE_URL}/static/\\attacker.com\share\x`);
954}
955"#;
956 let errors = K6ScriptGenerator::validate_script(bad);
957 assert!(
958 errors.iter().any(|e| e.contains("invalid JS hex escape")),
959 "expected hex-escape error, got {errors:#?}"
960 );
961 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
962 r#"http.get(`${BASE_URL}/static/\\attacker.com\share\x`)"#
963 )
964 .is_some());
965 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
966 r#"BASE_URL + "/static/\\\\attacker.com\\share\\x""#
967 )
968 .is_none());
969 }
970
971 #[test]
972 fn test_sanitize_js_identifier() {
973 assert_eq!(
975 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
976 "billing_subscriptions_v1"
977 );
978
979 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
981
982 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
984
985 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
987
988 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
990
991 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
993
994 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
996
997 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
999 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
1000 assert_eq!(
1001 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
1002 "plans_update_pricing_schemes"
1003 );
1004 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
1005 }
1006
1007 #[test]
1008 fn test_sanitize_k6_metric_name_short_passthrough() {
1009 let short = "billing_subscriptions_list";
1011 let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
1012 assert_eq!(out, short);
1013 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
1014 }
1015
1016 #[test]
1017 fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
1018 let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
1022 charts.workbookChart.axes.categoryAxis.format.line.clear";
1023 let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
1024
1025 assert!(
1027 metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
1028 "metric base len {} exceeded cap {}",
1029 metric.len(),
1030 K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
1031 );
1032
1033 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1035 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
1036 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
1037 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
1039 }
1040
1041 #[test]
1042 fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
1043 let prefix = "a".repeat(150);
1046 let a = format!("{prefix}.foo");
1047 let b = format!("{prefix}.bar");
1048 let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
1049 let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
1050 assert_ne!(ma, mb, "distinct long names produced the same metric name");
1051 }
1052
1053 #[test]
1054 fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
1055 let long = format!("{}123end", "x".repeat(120));
1057 let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
1058 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1059 }
1060
1061 #[test]
1062 fn test_microsoft_graph_long_operation_id_passes_validation() {
1063 use crate::spec_parser::ApiOperation;
1066 use openapiv3::Operation;
1067
1068 let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
1069 workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
1070 line.clear";
1071
1072 let operation = ApiOperation {
1073 method: "post".to_string(),
1074 path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
1075 operation: Operation::default(),
1076 operation_id: Some(long_op_id.to_string()),
1077 };
1078 let template = RequestTemplate {
1079 operation,
1080 path_params: HashMap::new(),
1081 query_params: HashMap::new(),
1082 headers: HashMap::new(),
1083 body: None,
1084 };
1085 let config = K6Config {
1086 target_url: "https://api.example.com".to_string(),
1087 base_path: Some("/v1.0".to_string()),
1088 scenario: LoadScenario::Constant,
1089 duration_secs: 30,
1090 max_vus: 5,
1091 threshold_percentile: "p(95)".to_string(),
1092 threshold_ms: 500,
1093 max_error_rate: 0.05,
1094 auth_header: None,
1095 custom_headers: HashMap::new(),
1096 skip_tls_verify: false,
1097 security_testing_enabled: false,
1098 chunked_request_bodies: false,
1099 target_rps: None,
1100 no_keep_alive: false,
1101 geo_source_ips: Vec::new(),
1102 geo_source_headers: Vec::new(),
1103 };
1104 let generator = K6ScriptGenerator::new(config, vec![template]);
1105 let script = generator.generate().expect("script generates");
1106
1107 let errors = K6ScriptGenerator::validate_script(&script);
1108 assert!(
1109 errors.is_empty(),
1110 "validate_script returned errors for long operationId: {errors:#?}"
1111 );
1112 }
1113
1114 #[test]
1119 fn test_abort_valve_opt_out_and_rate() {
1120 fn base_config() -> K6Config {
1121 K6Config {
1122 target_url: "https://api.example.com".to_string(),
1123 base_path: None,
1124 scenario: LoadScenario::Constant,
1125 duration_secs: 30,
1126 max_vus: 5,
1127 threshold_percentile: "p(95)".to_string(),
1128 threshold_ms: 500,
1129 max_error_rate: 0.05,
1130 auth_header: None,
1131 custom_headers: HashMap::new(),
1132 skip_tls_verify: false,
1133 security_testing_enabled: false,
1134 chunked_request_bodies: false,
1135 target_rps: None,
1136 no_keep_alive: false,
1137 geo_source_ips: Vec::new(),
1138 geo_source_headers: Vec::new(),
1139 }
1140 }
1141
1142 let default_script = K6ScriptGenerator::new(base_config(), vec![])
1144 .generate()
1145 .expect("script generates");
1146 assert!(
1147 default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
1148 "default script must keep the 0.95 abort valve"
1149 );
1150
1151 let stress_script = K6ScriptGenerator::new(base_config(), vec![])
1154 .with_abort_valve(false, 0.95)
1155 .generate()
1156 .expect("script generates");
1157 assert!(
1160 !stress_script.contains("abortOnFail: true"),
1161 "--no-abort-on-error must drop the abortOnFail threshold"
1162 );
1163 assert!(stress_script.contains("rate<0.05"));
1165
1166 let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
1168 .with_abort_valve(true, 0.99)
1169 .generate()
1170 .expect("script generates");
1171 assert!(
1172 tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
1173 "--abort-on-error-rate must retune the valve threshold"
1174 );
1175 }
1176
1177 #[test]
1178 fn test_script_generation_with_dots_in_name() {
1179 use crate::spec_parser::ApiOperation;
1180 use openapiv3::Operation;
1181
1182 let operation = ApiOperation {
1184 method: "get".to_string(),
1185 path: "/billing/subscriptions".to_string(),
1186 operation: Operation::default(),
1187 operation_id: Some("billing.subscriptions.v1".to_string()),
1188 };
1189
1190 let template = RequestTemplate {
1191 operation,
1192 path_params: HashMap::new(),
1193 query_params: HashMap::new(),
1194 headers: HashMap::new(),
1195 body: None,
1196 };
1197
1198 let config = K6Config {
1199 target_url: "https://api.example.com".to_string(),
1200 base_path: None,
1201 scenario: LoadScenario::Constant,
1202 duration_secs: 30,
1203 max_vus: 5,
1204 threshold_percentile: "p(95)".to_string(),
1205 threshold_ms: 500,
1206 max_error_rate: 0.05,
1207 auth_header: None,
1208 custom_headers: HashMap::new(),
1209 skip_tls_verify: false,
1210 security_testing_enabled: false,
1211 chunked_request_bodies: false,
1212 target_rps: None,
1213 no_keep_alive: false,
1214 geo_source_ips: Vec::new(),
1215 geo_source_headers: Vec::new(),
1216 };
1217
1218 let generator = K6ScriptGenerator::new(config, vec![template]);
1219 let script = generator.generate().expect("Should generate script");
1220
1221 assert!(
1223 script.contains("const billing_subscriptions_v1_latency"),
1224 "Script should contain sanitized variable name for latency"
1225 );
1226 assert!(
1227 script.contains("const billing_subscriptions_v1_errors"),
1228 "Script should contain sanitized variable name for errors"
1229 );
1230
1231 assert!(
1234 !script.contains("const billing.subscriptions"),
1235 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
1236 );
1237
1238 assert!(
1241 script.contains("'billing_subscriptions_v1_latency'"),
1242 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1243 );
1244 assert!(
1245 script.contains("'billing_subscriptions_v1_errors'"),
1246 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1247 );
1248
1249 assert!(
1251 script.contains("billing.subscriptions.v1"),
1252 "Script should contain original name in comments/strings for readability"
1253 );
1254
1255 assert!(
1257 script.contains("billing_subscriptions_v1_latency.add"),
1258 "Variable usage should use sanitized name"
1259 );
1260 assert!(
1261 script.contains("billing_subscriptions_v1_errors.add"),
1262 "Variable usage should use sanitized name"
1263 );
1264 }
1265
1266 #[test]
1273 fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
1274 use crate::spec_parser::ApiOperation;
1275 use openapiv3::Operation;
1276
1277 let operation = ApiOperation {
1278 method: "get".to_string(),
1279 path: "/users".to_string(),
1280 operation: Operation::default(),
1281 operation_id: Some("listUsers".to_string()),
1282 };
1283 let template = RequestTemplate {
1284 operation,
1285 path_params: HashMap::new(),
1286 query_params: HashMap::new(),
1287 headers: HashMap::new(),
1288 body: None,
1289 };
1290
1291 let config = K6Config {
1292 target_url: "https://api.example.com".to_string(),
1293 base_path: None,
1294 scenario: LoadScenario::RampUp,
1295 duration_secs: 600,
1296 max_vus: 100,
1297 threshold_percentile: "p(95)".to_string(),
1298 threshold_ms: 500,
1299 max_error_rate: 0.05,
1300 auth_header: None,
1301 custom_headers: HashMap::new(),
1302 skip_tls_verify: false,
1303 security_testing_enabled: false,
1304 chunked_request_bodies: false,
1305 target_rps: Some(100),
1306 no_keep_alive: false,
1307 geo_source_ips: Vec::new(),
1308 geo_source_headers: Vec::new(),
1309 };
1310
1311 let generator = K6ScriptGenerator::new(config, vec![template]);
1312 let script = generator.generate().expect("Should generate script");
1313
1314 assert!(
1315 script.contains("constant-arrival-rate"),
1316 "with --rps set, executor must switch to constant-arrival-rate"
1317 );
1318 assert!(
1319 script.contains("rate: 100,"),
1320 "constant-arrival-rate must use the configured --rps as `rate`"
1321 );
1322 assert!(
1323 script.contains("duration: '600s'"),
1324 "duration must come from --duration, not the ramp-down stage; got:\n{}",
1325 script
1326 );
1327 assert!(
1328 script.contains("preAllocatedVUs: 100,"),
1329 "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
1330 script
1331 );
1332 assert!(
1333 script.contains("maxVUs: 100,"),
1334 "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
1335 script
1336 );
1337 for (idx, line) in script.lines().enumerate() {
1341 let trimmed = line.trim_start();
1342 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1343 continue;
1344 }
1345 assert!(
1346 !trimmed.starts_with("preAllocatedVUs: 0"),
1347 "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
1348 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
1349 idx + 1,
1350 line,
1351 );
1352 }
1353 }
1354
1355 #[test]
1358 fn test_cps_sets_no_connection_reuse() {
1359 use crate::spec_parser::ApiOperation;
1360 use openapiv3::Operation;
1361
1362 let operation = ApiOperation {
1363 method: "get".to_string(),
1364 path: "/u".to_string(),
1365 operation: Operation::default(),
1366 operation_id: Some("u".to_string()),
1367 };
1368 let template = RequestTemplate {
1369 operation,
1370 path_params: HashMap::new(),
1371 query_params: HashMap::new(),
1372 headers: HashMap::new(),
1373 body: None,
1374 };
1375 let config = K6Config {
1376 target_url: "https://api.example.com".to_string(),
1377 base_path: None,
1378 scenario: LoadScenario::Constant,
1379 duration_secs: 30,
1380 max_vus: 5,
1381 threshold_percentile: "p(95)".to_string(),
1382 threshold_ms: 500,
1383 max_error_rate: 0.05,
1384 auth_header: None,
1385 custom_headers: HashMap::new(),
1386 skip_tls_verify: false,
1387 security_testing_enabled: false,
1388 chunked_request_bodies: false,
1389 target_rps: None,
1390 no_keep_alive: true,
1391 geo_source_ips: Vec::new(),
1392 geo_source_headers: Vec::new(),
1393 };
1394 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1395 assert!(
1396 script.contains("noConnectionReuse: true"),
1397 "--cps must set noConnectionReuse: true on the k6 options block"
1398 );
1399 assert!(
1400 script.contains("Total Connections:"),
1401 "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1402 );
1403 assert!(
1404 script.contains("Connection Rate:"),
1405 "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1406 );
1407 }
1408
1409 #[test]
1415 fn test_constant_scenario_starts_at_target_vus() {
1416 use crate::spec_parser::ApiOperation;
1417 use openapiv3::Operation;
1418
1419 let operation = ApiOperation {
1420 method: "get".to_string(),
1421 path: "/u".to_string(),
1422 operation: Operation::default(),
1423 operation_id: Some("u".to_string()),
1424 };
1425 let template = RequestTemplate {
1426 operation,
1427 path_params: HashMap::new(),
1428 query_params: HashMap::new(),
1429 headers: HashMap::new(),
1430 body: None,
1431 };
1432 let config = K6Config {
1433 target_url: "https://api.example.com".to_string(),
1434 base_path: None,
1435 scenario: LoadScenario::Constant,
1436 duration_secs: 600,
1437 max_vus: 5,
1438 threshold_percentile: "p(95)".to_string(),
1439 threshold_ms: 500,
1440 max_error_rate: 0.05,
1441 auth_header: None,
1442 custom_headers: HashMap::new(),
1443 skip_tls_verify: false,
1444 security_testing_enabled: false,
1445 chunked_request_bodies: false,
1446 target_rps: None,
1447 no_keep_alive: false,
1448 geo_source_ips: Vec::new(),
1449 geo_source_headers: Vec::new(),
1450 };
1451 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1452 assert!(
1453 script.contains("startVUs: 5,"),
1454 "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1455 script
1456 );
1457 let ramp_config = K6Config {
1459 target_url: "https://api.example.com".to_string(),
1460 base_path: None,
1461 scenario: LoadScenario::RampUp,
1462 duration_secs: 600,
1463 max_vus: 5,
1464 threshold_percentile: "p(95)".to_string(),
1465 threshold_ms: 500,
1466 max_error_rate: 0.05,
1467 auth_header: None,
1468 custom_headers: HashMap::new(),
1469 skip_tls_verify: false,
1470 security_testing_enabled: false,
1471 chunked_request_bodies: false,
1472 target_rps: None,
1473 no_keep_alive: false,
1474 geo_source_ips: Vec::new(),
1475 geo_source_headers: Vec::new(),
1476 };
1477 let ramp_template = RequestTemplate {
1478 operation: ApiOperation {
1479 method: "get".to_string(),
1480 path: "/u".to_string(),
1481 operation: Operation::default(),
1482 operation_id: Some("u".to_string()),
1483 },
1484 path_params: HashMap::new(),
1485 query_params: HashMap::new(),
1486 headers: HashMap::new(),
1487 body: None,
1488 };
1489 let ramp_script =
1490 K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1491 assert!(
1492 ramp_script.contains("startVUs: 0,"),
1493 "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1494 ramp_script
1495 );
1496 }
1497
1498 #[test]
1507 fn test_connections_opened_counter_present() {
1508 use crate::spec_parser::ApiOperation;
1509 use openapiv3::Operation;
1510
1511 let operation = ApiOperation {
1512 method: "get".to_string(),
1513 path: "/u".to_string(),
1514 operation: Operation::default(),
1515 operation_id: Some("u".to_string()),
1516 };
1517 let template = RequestTemplate {
1518 operation,
1519 path_params: HashMap::new(),
1520 query_params: HashMap::new(),
1521 headers: HashMap::new(),
1522 body: None,
1523 };
1524 let config = K6Config {
1525 target_url: "https://api.example.com".to_string(),
1526 base_path: None,
1527 scenario: LoadScenario::Constant,
1528 duration_secs: 30,
1529 max_vus: 5,
1530 threshold_percentile: "p(95)".to_string(),
1531 threshold_ms: 500,
1532 max_error_rate: 0.05,
1533 auth_header: None,
1534 custom_headers: HashMap::new(),
1535 skip_tls_verify: false,
1536 security_testing_enabled: false,
1537 chunked_request_bodies: false,
1538 target_rps: Some(50),
1539 no_keep_alive: false,
1540 geo_source_ips: Vec::new(),
1541 geo_source_headers: Vec::new(),
1542 };
1543 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1544 assert!(
1545 script.contains("new Counter('mockforge_connections_opened')"),
1546 "template must declare the mockforge_connections_opened Counter"
1547 );
1548 assert!(
1549 script.contains("mockforge_connections_opened.add(1)"),
1550 "template must increment mockforge_connections_opened on new TCP connect"
1551 );
1552 assert!(
1553 script.contains("res.timings.connecting > 0"),
1554 "template must gate the connection-opened increment on \
1555 res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_validate_script_valid() {
1561 let valid_script = r#"
1562import http from 'k6/http';
1563import { check, sleep } from 'k6';
1564import { Rate, Trend } from 'k6/metrics';
1565
1566const test_latency = new Trend('test_latency');
1567const test_errors = new Rate('test_errors');
1568
1569export default function() {
1570 const res = http.get('https://example.com');
1571 test_latency.add(res.timings.duration);
1572 test_errors.add(res.status !== 200);
1573}
1574"#;
1575
1576 let errors = K6ScriptGenerator::validate_script(valid_script);
1577 assert!(errors.is_empty(), "Valid script should have no validation errors");
1578 }
1579
1580 #[test]
1581 fn test_validate_script_invalid_metric_name() {
1582 let invalid_script = r#"
1583import http from 'k6/http';
1584import { check, sleep } from 'k6';
1585import { Rate, Trend } from 'k6/metrics';
1586
1587const test_latency = new Trend('test.latency');
1588const test_errors = new Rate('test_errors');
1589
1590export default function() {
1591 const res = http.get('https://example.com');
1592 test_latency.add(res.timings.duration);
1593}
1594"#;
1595
1596 let errors = K6ScriptGenerator::validate_script(invalid_script);
1597 assert!(
1598 !errors.is_empty(),
1599 "Script with invalid metric name should have validation errors"
1600 );
1601 assert!(
1602 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1603 "Should detect invalid metric name with dot"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_validate_script_missing_imports() {
1609 let invalid_script = r#"
1610const test_latency = new Trend('test_latency');
1611export default function() {}
1612"#;
1613
1614 let errors = K6ScriptGenerator::validate_script(invalid_script);
1615 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1616 }
1617
1618 #[test]
1619 fn test_validate_script_metric_name_validation() {
1620 let valid_script = r#"
1623import http from 'k6/http';
1624import { check, sleep } from 'k6';
1625import { Rate, Trend } from 'k6/metrics';
1626const test_latency = new Trend('test_latency');
1627const test_errors = new Rate('test_errors');
1628export default function() {}
1629"#;
1630 let errors = K6ScriptGenerator::validate_script(valid_script);
1631 assert!(errors.is_empty(), "Valid metric names should pass validation");
1632
1633 let invalid_cases = vec![
1635 ("test.latency", "dot in metric name"),
1636 ("123test", "starts with number"),
1637 ("test-latency", "hyphen in metric name"),
1638 ("test@latency", "special character"),
1639 ];
1640
1641 for (invalid_name, description) in invalid_cases {
1642 let script = format!(
1643 r#"
1644import http from 'k6/http';
1645import {{ check, sleep }} from 'k6';
1646import {{ Rate, Trend }} from 'k6/metrics';
1647const test_latency = new Trend('{}');
1648export default function() {{}}
1649"#,
1650 invalid_name
1651 );
1652 let errors = K6ScriptGenerator::validate_script(&script);
1653 assert!(
1654 !errors.is_empty(),
1655 "Metric name '{}' ({}) should fail validation",
1656 invalid_name,
1657 description
1658 );
1659 }
1660 }
1661
1662 #[test]
1663 fn test_skip_tls_verify_with_body() {
1664 use crate::spec_parser::ApiOperation;
1665 use openapiv3::Operation;
1666 use serde_json::json;
1667
1668 let operation = ApiOperation {
1670 method: "post".to_string(),
1671 path: "/api/users".to_string(),
1672 operation: Operation::default(),
1673 operation_id: Some("createUser".to_string()),
1674 };
1675
1676 let template = RequestTemplate {
1677 operation,
1678 path_params: HashMap::new(),
1679 query_params: HashMap::new(),
1680 headers: HashMap::new(),
1681 body: Some(json!({"name": "test"})),
1682 };
1683
1684 let config = K6Config {
1685 target_url: "https://api.example.com".to_string(),
1686 base_path: None,
1687 scenario: LoadScenario::Constant,
1688 duration_secs: 30,
1689 max_vus: 5,
1690 threshold_percentile: "p(95)".to_string(),
1691 threshold_ms: 500,
1692 max_error_rate: 0.05,
1693 auth_header: None,
1694 custom_headers: HashMap::new(),
1695 skip_tls_verify: true,
1696 security_testing_enabled: false,
1697 chunked_request_bodies: false,
1698 target_rps: None,
1699 no_keep_alive: false,
1700 geo_source_ips: Vec::new(),
1701 geo_source_headers: Vec::new(),
1702 };
1703
1704 let generator = K6ScriptGenerator::new(config, vec![template]);
1705 let script = generator.generate().expect("Should generate script");
1706
1707 assert!(
1709 script.contains("insecureSkipTLSVerify: true"),
1710 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1711 );
1712 }
1713
1714 #[test]
1715 fn test_skip_tls_verify_without_body() {
1716 use crate::spec_parser::ApiOperation;
1717 use openapiv3::Operation;
1718
1719 let operation = ApiOperation {
1721 method: "get".to_string(),
1722 path: "/api/users".to_string(),
1723 operation: Operation::default(),
1724 operation_id: Some("getUsers".to_string()),
1725 };
1726
1727 let template = RequestTemplate {
1728 operation,
1729 path_params: HashMap::new(),
1730 query_params: HashMap::new(),
1731 headers: HashMap::new(),
1732 body: None,
1733 };
1734
1735 let config = K6Config {
1736 target_url: "https://api.example.com".to_string(),
1737 base_path: None,
1738 scenario: LoadScenario::Constant,
1739 duration_secs: 30,
1740 max_vus: 5,
1741 threshold_percentile: "p(95)".to_string(),
1742 threshold_ms: 500,
1743 max_error_rate: 0.05,
1744 auth_header: None,
1745 custom_headers: HashMap::new(),
1746 skip_tls_verify: true,
1747 security_testing_enabled: false,
1748 chunked_request_bodies: false,
1749 target_rps: None,
1750 no_keep_alive: false,
1751 geo_source_ips: Vec::new(),
1752 geo_source_headers: Vec::new(),
1753 };
1754
1755 let generator = K6ScriptGenerator::new(config, vec![template]);
1756 let script = generator.generate().expect("Should generate script");
1757
1758 assert!(
1760 script.contains("insecureSkipTLSVerify: true"),
1761 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1762 );
1763 }
1764
1765 #[test]
1766 fn test_no_skip_tls_verify() {
1767 use crate::spec_parser::ApiOperation;
1768 use openapiv3::Operation;
1769
1770 let operation = ApiOperation {
1772 method: "get".to_string(),
1773 path: "/api/users".to_string(),
1774 operation: Operation::default(),
1775 operation_id: Some("getUsers".to_string()),
1776 };
1777
1778 let template = RequestTemplate {
1779 operation,
1780 path_params: HashMap::new(),
1781 query_params: HashMap::new(),
1782 headers: HashMap::new(),
1783 body: None,
1784 };
1785
1786 let config = K6Config {
1787 target_url: "https://api.example.com".to_string(),
1788 base_path: None,
1789 scenario: LoadScenario::Constant,
1790 duration_secs: 30,
1791 max_vus: 5,
1792 threshold_percentile: "p(95)".to_string(),
1793 threshold_ms: 500,
1794 max_error_rate: 0.05,
1795 auth_header: None,
1796 custom_headers: HashMap::new(),
1797 skip_tls_verify: false,
1798 security_testing_enabled: false,
1799 chunked_request_bodies: false,
1800 target_rps: None,
1801 no_keep_alive: false,
1802 geo_source_ips: Vec::new(),
1803 geo_source_headers: Vec::new(),
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("insecureSkipTLSVerify"),
1812 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_skip_tls_verify_multiple_operations() {
1818 use crate::spec_parser::ApiOperation;
1819 use openapiv3::Operation;
1820 use serde_json::json;
1821
1822 let operation1 = ApiOperation {
1824 method: "get".to_string(),
1825 path: "/api/users".to_string(),
1826 operation: Operation::default(),
1827 operation_id: Some("getUsers".to_string()),
1828 };
1829
1830 let operation2 = ApiOperation {
1831 method: "post".to_string(),
1832 path: "/api/users".to_string(),
1833 operation: Operation::default(),
1834 operation_id: Some("createUser".to_string()),
1835 };
1836
1837 let template1 = RequestTemplate {
1838 operation: operation1,
1839 path_params: HashMap::new(),
1840 query_params: HashMap::new(),
1841 headers: HashMap::new(),
1842 body: None,
1843 };
1844
1845 let template2 = RequestTemplate {
1846 operation: operation2,
1847 path_params: HashMap::new(),
1848 query_params: HashMap::new(),
1849 headers: HashMap::new(),
1850 body: Some(json!({"name": "test"})),
1851 };
1852
1853 let config = K6Config {
1854 target_url: "https://api.example.com".to_string(),
1855 base_path: None,
1856 scenario: LoadScenario::Constant,
1857 duration_secs: 30,
1858 max_vus: 5,
1859 threshold_percentile: "p(95)".to_string(),
1860 threshold_ms: 500,
1861 max_error_rate: 0.05,
1862 auth_header: None,
1863 custom_headers: HashMap::new(),
1864 skip_tls_verify: true,
1865 security_testing_enabled: false,
1866 chunked_request_bodies: false,
1867 target_rps: None,
1868 no_keep_alive: false,
1869 geo_source_ips: Vec::new(),
1870 geo_source_headers: Vec::new(),
1871 };
1872
1873 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
1874 let script = generator.generate().expect("Should generate script");
1875
1876 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
1879 assert_eq!(
1880 skip_count, 1,
1881 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
1882 );
1883
1884 let options_start = script.find("export const options = {").expect("Should have options");
1886 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
1887 let options_prefix = &script[options_start..scenarios_start];
1888 assert!(
1889 options_prefix.contains("insecureSkipTLSVerify: true"),
1890 "insecureSkipTLSVerify should be in global options block"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_dynamic_params_in_body() {
1896 use crate::spec_parser::ApiOperation;
1897 use openapiv3::Operation;
1898 use serde_json::json;
1899
1900 let operation = ApiOperation {
1902 method: "post".to_string(),
1903 path: "/api/resources".to_string(),
1904 operation: Operation::default(),
1905 operation_id: Some("createResource".to_string()),
1906 };
1907
1908 let template = RequestTemplate {
1909 operation,
1910 path_params: HashMap::new(),
1911 query_params: HashMap::new(),
1912 headers: HashMap::new(),
1913 body: Some(json!({
1914 "name": "load-test-${__VU}",
1915 "iteration": "${__ITER}"
1916 })),
1917 };
1918
1919 let config = K6Config {
1920 target_url: "https://api.example.com".to_string(),
1921 base_path: None,
1922 scenario: LoadScenario::Constant,
1923 duration_secs: 30,
1924 max_vus: 5,
1925 threshold_percentile: "p(95)".to_string(),
1926 threshold_ms: 500,
1927 max_error_rate: 0.05,
1928 auth_header: None,
1929 custom_headers: HashMap::new(),
1930 skip_tls_verify: false,
1931 security_testing_enabled: false,
1932 chunked_request_bodies: false,
1933 target_rps: None,
1934 no_keep_alive: false,
1935 geo_source_ips: Vec::new(),
1936 geo_source_headers: Vec::new(),
1937 };
1938
1939 let generator = K6ScriptGenerator::new(config, vec![template]);
1940 let script = generator.generate().expect("Should generate script");
1941
1942 assert!(
1944 script.contains("Dynamic body with runtime placeholders"),
1945 "Script should contain comment about dynamic body"
1946 );
1947
1948 assert!(
1950 script.contains("__VU"),
1951 "Script should contain __VU reference for dynamic VU-based values"
1952 );
1953
1954 assert!(
1956 script.contains("__ITER"),
1957 "Script should contain __ITER reference for dynamic iteration values"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_dynamic_params_with_uuid() {
1963 use crate::spec_parser::ApiOperation;
1964 use openapiv3::Operation;
1965 use serde_json::json;
1966
1967 let operation = ApiOperation {
1969 method: "post".to_string(),
1970 path: "/api/resources".to_string(),
1971 operation: Operation::default(),
1972 operation_id: Some("createResource".to_string()),
1973 };
1974
1975 let template = RequestTemplate {
1976 operation,
1977 path_params: HashMap::new(),
1978 query_params: HashMap::new(),
1979 headers: HashMap::new(),
1980 body: Some(json!({
1981 "id": "${__UUID}"
1982 })),
1983 };
1984
1985 let config = K6Config {
1986 target_url: "https://api.example.com".to_string(),
1987 base_path: None,
1988 scenario: LoadScenario::Constant,
1989 duration_secs: 30,
1990 max_vus: 5,
1991 threshold_percentile: "p(95)".to_string(),
1992 threshold_ms: 500,
1993 max_error_rate: 0.05,
1994 auth_header: None,
1995 custom_headers: HashMap::new(),
1996 skip_tls_verify: false,
1997 security_testing_enabled: false,
1998 chunked_request_bodies: false,
1999 target_rps: None,
2000 no_keep_alive: false,
2001 geo_source_ips: Vec::new(),
2002 geo_source_headers: Vec::new(),
2003 };
2004
2005 let generator = K6ScriptGenerator::new(config, vec![template]);
2006 let script = generator.generate().expect("Should generate script");
2007
2008 assert!(
2011 !script.contains("k6/experimental/webcrypto"),
2012 "Script should NOT include deprecated k6/experimental/webcrypto import"
2013 );
2014
2015 assert!(
2017 script.contains("crypto.randomUUID()"),
2018 "Script should contain crypto.randomUUID() for UUID placeholder"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_dynamic_params_with_counter() {
2024 use crate::spec_parser::ApiOperation;
2025 use openapiv3::Operation;
2026 use serde_json::json;
2027
2028 let operation = ApiOperation {
2030 method: "post".to_string(),
2031 path: "/api/resources".to_string(),
2032 operation: Operation::default(),
2033 operation_id: Some("createResource".to_string()),
2034 };
2035
2036 let template = RequestTemplate {
2037 operation,
2038 path_params: HashMap::new(),
2039 query_params: HashMap::new(),
2040 headers: HashMap::new(),
2041 body: Some(json!({
2042 "sequence": "${__COUNTER}"
2043 })),
2044 };
2045
2046 let config = K6Config {
2047 target_url: "https://api.example.com".to_string(),
2048 base_path: None,
2049 scenario: LoadScenario::Constant,
2050 duration_secs: 30,
2051 max_vus: 5,
2052 threshold_percentile: "p(95)".to_string(),
2053 threshold_ms: 500,
2054 max_error_rate: 0.05,
2055 auth_header: None,
2056 custom_headers: HashMap::new(),
2057 skip_tls_verify: false,
2058 security_testing_enabled: false,
2059 chunked_request_bodies: false,
2060 target_rps: None,
2061 no_keep_alive: false,
2062 geo_source_ips: Vec::new(),
2063 geo_source_headers: Vec::new(),
2064 };
2065
2066 let generator = K6ScriptGenerator::new(config, vec![template]);
2067 let script = generator.generate().expect("Should generate script");
2068
2069 assert!(
2071 script.contains("let globalCounter = 0"),
2072 "Script should include globalCounter initialization when COUNTER placeholder is used"
2073 );
2074
2075 assert!(
2077 script.contains("globalCounter++"),
2078 "Script should contain globalCounter++ for COUNTER placeholder"
2079 );
2080 }
2081
2082 #[test]
2083 fn test_static_body_no_dynamic_marker() {
2084 use crate::spec_parser::ApiOperation;
2085 use openapiv3::Operation;
2086 use serde_json::json;
2087
2088 let operation = ApiOperation {
2090 method: "post".to_string(),
2091 path: "/api/resources".to_string(),
2092 operation: Operation::default(),
2093 operation_id: Some("createResource".to_string()),
2094 };
2095
2096 let template = RequestTemplate {
2097 operation,
2098 path_params: HashMap::new(),
2099 query_params: HashMap::new(),
2100 headers: HashMap::new(),
2101 body: Some(json!({
2102 "name": "static-value",
2103 "count": 42
2104 })),
2105 };
2106
2107 let config = K6Config {
2108 target_url: "https://api.example.com".to_string(),
2109 base_path: None,
2110 scenario: LoadScenario::Constant,
2111 duration_secs: 30,
2112 max_vus: 5,
2113 threshold_percentile: "p(95)".to_string(),
2114 threshold_ms: 500,
2115 max_error_rate: 0.05,
2116 auth_header: None,
2117 custom_headers: HashMap::new(),
2118 skip_tls_verify: false,
2119 security_testing_enabled: false,
2120 chunked_request_bodies: false,
2121 target_rps: None,
2122 no_keep_alive: false,
2123 geo_source_ips: Vec::new(),
2124 geo_source_headers: Vec::new(),
2125 };
2126
2127 let generator = K6ScriptGenerator::new(config, vec![template]);
2128 let script = generator.generate().expect("Should generate script");
2129
2130 assert!(
2132 !script.contains("Dynamic body with runtime placeholders"),
2133 "Script should NOT contain dynamic body comment for static body"
2134 );
2135
2136 assert!(
2138 !script.contains("webcrypto"),
2139 "Script should NOT include webcrypto import for static body"
2140 );
2141
2142 assert!(
2144 !script.contains("let globalCounter"),
2145 "Script should NOT include globalCounter for static body"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_security_testing_enabled_generates_calling_code() {
2151 use crate::spec_parser::ApiOperation;
2152 use openapiv3::Operation;
2153 use serde_json::json;
2154
2155 let operation = ApiOperation {
2156 method: "post".to_string(),
2157 path: "/api/users".to_string(),
2158 operation: Operation::default(),
2159 operation_id: Some("createUser".to_string()),
2160 };
2161
2162 let template = RequestTemplate {
2163 operation,
2164 path_params: HashMap::new(),
2165 query_params: HashMap::new(),
2166 headers: HashMap::new(),
2167 body: Some(json!({"name": "test"})),
2168 };
2169
2170 let config = K6Config {
2171 target_url: "https://api.example.com".to_string(),
2172 base_path: None,
2173 scenario: LoadScenario::Constant,
2174 duration_secs: 30,
2175 max_vus: 5,
2176 threshold_percentile: "p(95)".to_string(),
2177 threshold_ms: 500,
2178 max_error_rate: 0.05,
2179 auth_header: None,
2180 custom_headers: HashMap::new(),
2181 skip_tls_verify: false,
2182 security_testing_enabled: true,
2183 chunked_request_bodies: false,
2184 target_rps: None,
2185 no_keep_alive: false,
2186 geo_source_ips: Vec::new(),
2187 geo_source_headers: Vec::new(),
2188 };
2189
2190 let generator = K6ScriptGenerator::new(config, vec![template]);
2191 let script = generator.generate().expect("Should generate script");
2192
2193 assert!(
2195 script.contains("getNextSecurityPayload"),
2196 "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
2197 );
2198 assert!(
2199 script.contains("applySecurityPayload"),
2200 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
2201 );
2202 assert!(
2203 script.contains("secPayloadGroup"),
2204 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
2205 );
2206 assert!(
2207 script.contains("secBodyPayload"),
2208 "Script should contain secBodyPayload variable when security_testing_enabled is true"
2209 );
2210 assert!(
2212 script.contains("hasSecCookie"),
2213 "Script should track hasSecCookie for CookieJar conflict avoidance"
2214 );
2215 assert!(
2216 script.contains("secRequestOpts"),
2217 "Script should use secRequestOpts to conditionally skip CookieJar"
2218 );
2219 assert!(
2221 script.contains("const requestHeaders = { ..."),
2222 "Script should spread headers into mutable copy for security payload injection"
2223 );
2224 assert!(
2226 script.contains("secPayload.injectAsPath"),
2227 "Script should check injectAsPath for path-based URI injection"
2228 );
2229 assert!(
2231 script.contains("secBodyPayload.formBody"),
2232 "Script should check formBody for form-encoded body delivery"
2233 );
2234 assert!(
2235 script.contains("application/x-www-form-urlencoded"),
2236 "Script should set Content-Type for form-encoded body"
2237 );
2238 let op_comment_pos =
2240 script.find("// Operation 0:").expect("Should have Operation 0 comment");
2241 let sec_payload_pos = script
2242 .find("const secPayloadGroup = typeof getNextSecurityPayload")
2243 .expect("Should have secPayloadGroup assignment");
2244 assert!(
2245 sec_payload_pos > op_comment_pos,
2246 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_security_testing_disabled_no_calling_code() {
2252 use crate::spec_parser::ApiOperation;
2253 use openapiv3::Operation;
2254 use serde_json::json;
2255
2256 let operation = ApiOperation {
2257 method: "post".to_string(),
2258 path: "/api/users".to_string(),
2259 operation: Operation::default(),
2260 operation_id: Some("createUser".to_string()),
2261 };
2262
2263 let template = RequestTemplate {
2264 operation,
2265 path_params: HashMap::new(),
2266 query_params: HashMap::new(),
2267 headers: HashMap::new(),
2268 body: Some(json!({"name": "test"})),
2269 };
2270
2271 let config = K6Config {
2272 target_url: "https://api.example.com".to_string(),
2273 base_path: None,
2274 scenario: LoadScenario::Constant,
2275 duration_secs: 30,
2276 max_vus: 5,
2277 threshold_percentile: "p(95)".to_string(),
2278 threshold_ms: 500,
2279 max_error_rate: 0.05,
2280 auth_header: None,
2281 custom_headers: HashMap::new(),
2282 skip_tls_verify: false,
2283 security_testing_enabled: false,
2284 chunked_request_bodies: false,
2285 target_rps: None,
2286 no_keep_alive: false,
2287 geo_source_ips: Vec::new(),
2288 geo_source_headers: Vec::new(),
2289 };
2290
2291 let generator = K6ScriptGenerator::new(config, vec![template]);
2292 let script = generator.generate().expect("Should generate script");
2293
2294 assert!(
2296 !script.contains("getNextSecurityPayload"),
2297 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
2298 );
2299 assert!(
2300 !script.contains("applySecurityPayload"),
2301 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
2302 );
2303 assert!(
2304 !script.contains("secPayloadGroup"),
2305 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
2306 );
2307 assert!(
2308 !script.contains("secBodyPayload"),
2309 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
2310 );
2311 assert!(
2312 !script.contains("hasSecCookie"),
2313 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
2314 );
2315 assert!(
2316 !script.contains("secRequestOpts"),
2317 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
2318 );
2319 assert!(
2320 !script.contains("injectAsPath"),
2321 "Script should NOT contain injectAsPath when security_testing_enabled is false"
2322 );
2323 assert!(
2324 !script.contains("formBody"),
2325 "Script should NOT contain formBody when security_testing_enabled is false"
2326 );
2327 }
2328
2329 #[test]
2333 fn test_security_e2e_definitions_and_calls_both_present() {
2334 use crate::security_payloads::{
2335 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
2336 };
2337 use crate::spec_parser::ApiOperation;
2338 use openapiv3::Operation;
2339 use serde_json::json;
2340
2341 let operation = ApiOperation {
2343 method: "post".to_string(),
2344 path: "/api/users".to_string(),
2345 operation: Operation::default(),
2346 operation_id: Some("createUser".to_string()),
2347 };
2348
2349 let template = RequestTemplate {
2350 operation,
2351 path_params: HashMap::new(),
2352 query_params: HashMap::new(),
2353 headers: HashMap::new(),
2354 body: Some(json!({"name": "test"})),
2355 };
2356
2357 let config = K6Config {
2358 target_url: "https://api.example.com".to_string(),
2359 base_path: None,
2360 scenario: LoadScenario::Constant,
2361 duration_secs: 30,
2362 max_vus: 5,
2363 threshold_percentile: "p(95)".to_string(),
2364 threshold_ms: 500,
2365 max_error_rate: 0.05,
2366 auth_header: None,
2367 custom_headers: HashMap::new(),
2368 skip_tls_verify: false,
2369 security_testing_enabled: true,
2370 chunked_request_bodies: false,
2371 target_rps: None,
2372 no_keep_alive: false,
2373 geo_source_ips: Vec::new(),
2374 geo_source_headers: Vec::new(),
2375 };
2376
2377 let generator = K6ScriptGenerator::new(config, vec![template]);
2378 let mut script = generator.generate().expect("Should generate base script");
2379
2380 let security_config = SecurityTestConfig::default().enable();
2382 let payloads = SecurityPayloads::get_payloads(&security_config);
2383 assert!(!payloads.is_empty(), "Should have built-in payloads");
2384
2385 let mut additional_code = String::new();
2386 additional_code
2387 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
2388 additional_code.push('\n');
2389 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
2390 additional_code.push('\n');
2391
2392 if let Some(pos) = script.find("export const options") {
2394 script.insert_str(
2395 pos,
2396 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
2397 );
2398 }
2399
2400 assert!(
2403 script.contains("function getNextSecurityPayload()"),
2404 "Final script must contain getNextSecurityPayload function DEFINITION"
2405 );
2406 assert!(
2407 script.contains("function applySecurityPayload("),
2408 "Final script must contain applySecurityPayload function DEFINITION"
2409 );
2410 assert!(
2411 script.contains("securityPayloads"),
2412 "Final script must contain securityPayloads array"
2413 );
2414
2415 assert!(
2417 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2418 "Final script must contain secPayloadGroup assignment (template calling code)"
2419 );
2420 assert!(
2421 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2422 "Final script must contain applySecurityPayload CALL with secBodyPayload"
2423 );
2424 assert!(
2425 script.contains("const requestHeaders = { ..."),
2426 "Final script must spread headers for security payload header injection"
2427 );
2428 assert!(
2429 script.contains("for (const secPayload of secPayloadGroup)"),
2430 "Final script must loop over secPayloadGroup"
2431 );
2432 assert!(
2433 script.contains("secPayload.injectAsPath"),
2434 "Final script must check injectAsPath for path-based URI injection"
2435 );
2436 assert!(
2437 script.contains("secBodyPayload.formBody"),
2438 "Final script must check formBody for form-encoded body delivery"
2439 );
2440
2441 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2443 let call_pos =
2444 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2445 let options_pos = script.find("export const options").unwrap();
2446 let default_fn_pos = script.find("export default function").unwrap();
2447
2448 assert!(
2449 def_pos < options_pos,
2450 "Function definitions must appear before export const options"
2451 );
2452 assert!(
2453 call_pos > default_fn_pos,
2454 "Calling code must appear inside export default function"
2455 );
2456 }
2457
2458 #[test]
2460 fn test_security_uri_injection_for_get_requests() {
2461 use crate::spec_parser::ApiOperation;
2462 use openapiv3::Operation;
2463
2464 let operation = ApiOperation {
2465 method: "get".to_string(),
2466 path: "/api/users".to_string(),
2467 operation: Operation::default(),
2468 operation_id: Some("listUsers".to_string()),
2469 };
2470
2471 let template = RequestTemplate {
2472 operation,
2473 path_params: HashMap::new(),
2474 query_params: HashMap::new(),
2475 headers: HashMap::new(),
2476 body: None,
2477 };
2478
2479 let config = K6Config {
2480 target_url: "https://api.example.com".to_string(),
2481 base_path: None,
2482 scenario: LoadScenario::Constant,
2483 duration_secs: 30,
2484 max_vus: 5,
2485 threshold_percentile: "p(95)".to_string(),
2486 threshold_ms: 500,
2487 max_error_rate: 0.05,
2488 auth_header: None,
2489 custom_headers: HashMap::new(),
2490 skip_tls_verify: false,
2491 security_testing_enabled: true,
2492 chunked_request_bodies: false,
2493 target_rps: None,
2494 no_keep_alive: false,
2495 geo_source_ips: Vec::new(),
2496 geo_source_headers: Vec::new(),
2497 };
2498
2499 let generator = K6ScriptGenerator::new(config, vec![template]);
2500 let script = generator.generate().expect("Should generate script");
2501
2502 assert!(
2504 script.contains("requestUrl"),
2505 "Script should build requestUrl variable for URI payload injection"
2506 );
2507 assert!(
2508 script.contains("secPayload.location === 'uri'"),
2509 "Script should check for URI-location payloads"
2510 );
2511 assert!(
2513 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2514 "Script should URL-encode security payload in query string for valid HTTP"
2515 );
2516 assert!(
2518 script.contains("secPayload.injectAsPath"),
2519 "Script should check injectAsPath for path-based URI injection"
2520 );
2521 assert!(
2522 script.contains("encodeURI(secPayload.payload)"),
2523 "Script should use encodeURI for path-based injection"
2524 );
2525 assert!(
2527 script.contains("http.get(requestUrl,"),
2528 "GET request should use requestUrl (with URI injection) instead of inline URL"
2529 );
2530 }
2531
2532 #[test]
2534 fn test_security_uri_injection_for_post_requests() {
2535 use crate::spec_parser::ApiOperation;
2536 use openapiv3::Operation;
2537 use serde_json::json;
2538
2539 let operation = ApiOperation {
2540 method: "post".to_string(),
2541 path: "/api/users".to_string(),
2542 operation: Operation::default(),
2543 operation_id: Some("createUser".to_string()),
2544 };
2545
2546 let template = RequestTemplate {
2547 operation,
2548 path_params: HashMap::new(),
2549 query_params: HashMap::new(),
2550 headers: HashMap::new(),
2551 body: Some(json!({"name": "test"})),
2552 };
2553
2554 let config = K6Config {
2555 target_url: "https://api.example.com".to_string(),
2556 base_path: None,
2557 scenario: LoadScenario::Constant,
2558 duration_secs: 30,
2559 max_vus: 5,
2560 threshold_percentile: "p(95)".to_string(),
2561 threshold_ms: 500,
2562 max_error_rate: 0.05,
2563 auth_header: None,
2564 custom_headers: HashMap::new(),
2565 skip_tls_verify: false,
2566 security_testing_enabled: true,
2567 chunked_request_bodies: false,
2568 target_rps: None,
2569 no_keep_alive: false,
2570 geo_source_ips: Vec::new(),
2571 geo_source_headers: Vec::new(),
2572 };
2573
2574 let generator = K6ScriptGenerator::new(config, vec![template]);
2575 let script = generator.generate().expect("Should generate script");
2576
2577 assert!(
2579 script.contains("requestUrl"),
2580 "POST script should build requestUrl for URI payload injection"
2581 );
2582 assert!(
2583 script.contains("secPayload.location === 'uri'"),
2584 "POST script should check for URI-location payloads"
2585 );
2586 assert!(
2587 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2588 "POST script should apply security body payload to request body"
2589 );
2590 assert!(
2592 script.contains("http.post(requestUrl,"),
2593 "POST request should use requestUrl (with URI injection) instead of inline URL"
2594 );
2595 }
2596
2597 #[test]
2599 fn test_no_uri_injection_when_security_disabled() {
2600 use crate::spec_parser::ApiOperation;
2601 use openapiv3::Operation;
2602
2603 let operation = ApiOperation {
2604 method: "get".to_string(),
2605 path: "/api/users".to_string(),
2606 operation: Operation::default(),
2607 operation_id: Some("listUsers".to_string()),
2608 };
2609
2610 let template = RequestTemplate {
2611 operation,
2612 path_params: HashMap::new(),
2613 query_params: HashMap::new(),
2614 headers: HashMap::new(),
2615 body: None,
2616 };
2617
2618 let config = K6Config {
2619 target_url: "https://api.example.com".to_string(),
2620 base_path: None,
2621 scenario: LoadScenario::Constant,
2622 duration_secs: 30,
2623 max_vus: 5,
2624 threshold_percentile: "p(95)".to_string(),
2625 threshold_ms: 500,
2626 max_error_rate: 0.05,
2627 auth_header: None,
2628 custom_headers: HashMap::new(),
2629 skip_tls_verify: false,
2630 security_testing_enabled: false,
2631 chunked_request_bodies: false,
2632 target_rps: None,
2633 no_keep_alive: false,
2634 geo_source_ips: Vec::new(),
2635 geo_source_headers: Vec::new(),
2636 };
2637
2638 let generator = K6ScriptGenerator::new(config, vec![template]);
2639 let script = generator.generate().expect("Should generate script");
2640
2641 assert!(
2643 !script.contains("requestUrl"),
2644 "Script should NOT have requestUrl when security is disabled"
2645 );
2646 assert!(
2647 !script.contains("secPayloadGroup"),
2648 "Script should NOT have secPayloadGroup when security is disabled"
2649 );
2650 assert!(
2651 !script.contains("secBodyPayload"),
2652 "Script should NOT have secBodyPayload when security is disabled"
2653 );
2654 }
2655
2656 #[test]
2658 fn test_uses_per_request_cookie_jar() {
2659 use crate::spec_parser::ApiOperation;
2660 use openapiv3::Operation;
2661
2662 let operation = ApiOperation {
2663 method: "get".to_string(),
2664 path: "/api/users".to_string(),
2665 operation: Operation::default(),
2666 operation_id: Some("listUsers".to_string()),
2667 };
2668
2669 let template = RequestTemplate {
2670 operation,
2671 path_params: HashMap::new(),
2672 query_params: HashMap::new(),
2673 headers: HashMap::new(),
2674 body: None,
2675 };
2676
2677 let config = K6Config {
2678 target_url: "https://api.example.com".to_string(),
2679 base_path: None,
2680 scenario: LoadScenario::Constant,
2681 duration_secs: 30,
2682 max_vus: 5,
2683 threshold_percentile: "p(95)".to_string(),
2684 threshold_ms: 500,
2685 max_error_rate: 0.05,
2686 auth_header: None,
2687 custom_headers: HashMap::new(),
2688 skip_tls_verify: false,
2689 security_testing_enabled: false,
2690 chunked_request_bodies: false,
2691 target_rps: None,
2692 no_keep_alive: false,
2693 geo_source_ips: Vec::new(),
2694 geo_source_headers: Vec::new(),
2695 };
2696
2697 let generator = K6ScriptGenerator::new(config, vec![template]);
2698 let script = generator.generate().expect("Should generate script");
2699
2700 assert!(
2702 script.contains("jar: new http.CookieJar()"),
2703 "Script should create fresh CookieJar per request"
2704 );
2705 assert!(
2706 !script.contains("jar: null"),
2707 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2708 );
2709 assert!(
2710 !script.contains("EMPTY_JAR"),
2711 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2712 );
2713 }
2714}