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 pub force_http1: bool,
107}
108
109#[derive(Debug, Clone, Serialize)]
111pub struct K6CrudFlowTemplateData {
112 pub base_url: String,
113 pub flows: Vec<Value>,
114 pub extract_fields: Vec<String>,
115 pub duration_secs: u64,
116 pub max_vus: u32,
117 pub auth_header: Option<String>,
118 pub custom_headers: HashMap<String, String>,
119 pub skip_tls_verify: bool,
120 pub stages: Vec<K6StageData>,
121 pub threshold_percentile: String,
122 pub threshold_ms: u64,
123 pub max_error_rate: f64,
124 pub headers: String,
126 pub dynamic_imports: Vec<String>,
127 pub dynamic_globals: Vec<String>,
128 pub extracted_values_output_path: String,
129 pub error_injection_enabled: bool,
130 pub error_rate: f64,
131 pub error_types: Vec<String>,
132 pub security_testing_enabled: bool,
133 pub has_custom_headers: bool,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct K6StageData {
139 pub duration: String,
140 pub target: u32,
141}
142
143#[derive(Debug, Clone, Serialize)]
145pub struct K6OperationData {
146 pub index: usize,
147 pub name: String,
148 pub metric_name: String,
149 pub display_name: String,
150 pub method: String,
151 pub path: Value,
152 pub path_is_dynamic: bool,
153 pub headers: Value,
154 pub body: Option<Value>,
155 pub body_is_dynamic: bool,
156 pub has_body: bool,
157 pub is_get_or_head: bool,
158}
159
160pub struct K6Config {
162 pub target_url: String,
163 pub base_path: Option<String>,
166 pub scenario: LoadScenario,
167 pub duration_secs: u64,
168 pub max_vus: u32,
169 pub threshold_percentile: String,
170 pub threshold_ms: u64,
171 pub max_error_rate: f64,
172 pub auth_header: Option<String>,
173 pub custom_headers: HashMap<String, String>,
174 pub skip_tls_verify: bool,
175 pub security_testing_enabled: bool,
176 pub chunked_request_bodies: bool,
179 pub target_rps: Option<u32>,
182 pub no_keep_alive: bool,
185 pub geo_source_ips: Vec<String>,
189 pub geo_source_headers: Vec<String>,
193}
194
195pub struct K6ScriptGenerator {
197 config: K6Config,
198 templates: Vec<RequestTemplate>,
199 abort_on_error: bool,
202 abort_on_error_rate: f64,
205 force_http1: bool,
209}
210
211impl K6ScriptGenerator {
212 pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
218 Self {
219 config,
220 templates,
221 abort_on_error: true,
222 abort_on_error_rate: 0.95,
223 force_http1: false,
224 }
225 }
226
227 #[must_use]
231 pub fn with_force_http1(mut self, force_http1: bool) -> Self {
232 self.force_http1 = force_http1;
233 self
234 }
235
236 #[must_use]
244 pub fn with_abort_valve(mut self, abort_on_error: bool, abort_on_error_rate: f64) -> Self {
245 self.abort_on_error = abort_on_error;
246 self.abort_on_error_rate = abort_on_error_rate;
247 self
248 }
249
250 pub fn should_force_http1(&self) -> bool {
253 crate::request_gen::should_force_k6_http1(
254 self.force_http1,
255 &self.templates,
256 &self.config.custom_headers,
257 )
258 }
259
260 pub fn generate(&self) -> Result<String> {
262 let handlebars = Handlebars::new();
263
264 let template = include_str!("templates/k6_script.hbs");
265
266 let data = self.build_template_data()?;
267
268 let value = serde_json::to_value(&data)
269 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
270
271 handlebars
272 .render_template(template, &value)
273 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
274 }
275
276 const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
282
283 pub fn sanitize_k6_metric_name(name: &str) -> String {
296 let sanitized = Self::sanitize_js_identifier(name);
297 if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
298 return sanitized;
299 }
300
301 use std::collections::hash_map::DefaultHasher;
302 use std::hash::{Hash, Hasher};
303 let mut hasher = DefaultHasher::new();
304 name.hash(&mut hasher);
308 let hash_suffix = format!("{:08x}", hasher.finish() as u32);
309
310 let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
312 let prefix = &sanitized[..prefix_len];
313 let prefix = prefix.trim_end_matches('_');
315 format!("{}_{}", prefix, hash_suffix)
316 }
317
318 fn uniquify_name(base: String, used: &mut HashSet<String>) -> String {
326 if used.insert(base.clone()) {
327 return base;
328 }
329 let mut n = 2u32;
330 loop {
331 let candidate = format!("{base}_{n}");
332 if used.insert(candidate.clone()) {
333 return candidate;
334 }
335 n = n.saturating_add(1);
336 if n == u32::MAX {
337 use std::collections::hash_map::DefaultHasher;
338 use std::hash::{Hash, Hasher};
339 let mut hasher = DefaultHasher::new();
340 base.hash(&mut hasher);
341 used.len().hash(&mut hasher);
342 let fallback = format!("{base}_{:08x}", hasher.finish() as u32);
343 used.insert(fallback.clone());
344 return fallback;
345 }
346 }
347 }
348
349 pub fn sanitize_js_identifier(name: &str) -> String {
359 let mut result = String::new();
360 let mut chars = name.chars().peekable();
361
362 if let Some(&first) = chars.peek() {
364 if first.is_ascii_digit() {
365 result.push('_');
366 }
367 }
368
369 for ch in chars {
370 if ch.is_ascii_alphanumeric() || ch == '_' {
371 result.push(ch);
372 } else {
373 if !result.ends_with('_') {
376 result.push('_');
377 }
378 }
379 }
380
381 result = result.trim_end_matches('_').to_string();
383
384 if result.is_empty() {
386 result = "operation".to_string();
387 }
388
389 result
390 }
391
392 fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
394 let stages = self
395 .config
396 .scenario
397 .generate_stages(self.config.duration_secs, self.config.max_vus);
398
399 let base_path = self.config.base_path.as_deref().unwrap_or("");
401
402 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
404 let mut used_js_names: HashSet<String> = HashSet::new();
407 let mut used_metric_names: HashSet<String> = HashSet::new();
408
409 let mut operations = Vec::with_capacity(self.templates.len());
410 for (idx, template) in self.templates.iter().enumerate() {
411 let display_name = template.operation.display_name();
412 let sanitized_name = Self::uniquify_name(
413 Self::sanitize_js_identifier(&display_name),
414 &mut used_js_names,
415 );
416 let metric_name = Self::uniquify_name(
424 Self::sanitize_k6_metric_name(&display_name),
425 &mut used_metric_names,
426 );
427 let k6_method = match template.operation.method.to_lowercase().as_str() {
429 "delete" => "del".to_string(),
430 m => m.to_string(),
431 };
432 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
435
436 let raw_path = template.generate_path();
439 let full_path = join_base_path(base_path, &raw_path);
440 let processed_path = DynamicParamProcessor::process_path(&full_path);
441 all_placeholders.extend(processed_path.placeholders.clone());
442
443 let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
445 let processed_body = DynamicParamProcessor::process_json_body(body);
446 all_placeholders.extend(processed_body.placeholders.clone());
447 (Some(processed_body.value), processed_body.is_dynamic)
448 } else {
449 (None, false)
450 };
451
452 let path_value = if processed_path.is_dynamic {
462 processed_path.value
463 } else {
464 serde_json::to_string(&full_path).unwrap_or_else(|_| "\"/\"".to_string())
465 };
466
467 operations.push(K6OperationData {
468 index: idx,
469 name: sanitized_name,
470 metric_name,
471 display_name,
472 method: k6_method,
473 path: Value::String(path_value),
474 path_is_dynamic: processed_path.is_dynamic,
475 headers: Value::String(self.build_headers_json(template)),
476 body: body_value.map(Value::String),
477 body_is_dynamic,
478 has_body: template.body.is_some(),
479 is_get_or_head,
480 });
481 }
482
483 let required_imports: Vec<String> =
485 DynamicParamProcessor::get_required_imports(&all_placeholders)
486 .into_iter()
487 .map(String::from)
488 .collect();
489 let required_globals: Vec<String> =
490 DynamicParamProcessor::get_required_globals(&all_placeholders)
491 .into_iter()
492 .map(String::from)
493 .collect();
494 let has_dynamic_values = !all_placeholders.is_empty();
495
496 Ok(K6ScriptTemplateData {
497 base_url: self.config.target_url.clone(),
498 stages: stages
499 .iter()
500 .map(|s| K6StageData {
501 duration: s.duration.clone(),
502 target: s.target,
503 })
504 .collect(),
505 operations,
506 threshold_percentile: self.config.threshold_percentile.clone(),
507 threshold_ms: self.config.threshold_ms,
508 max_error_rate: self.config.max_error_rate,
509 abort_on_error: self.abort_on_error,
510 abort_on_error_rate: self.abort_on_error_rate,
511 scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
512 skip_tls_verify: self.config.skip_tls_verify,
513 has_dynamic_values,
514 dynamic_imports: required_imports,
515 dynamic_globals: required_globals,
516 security_testing_enabled: self.config.security_testing_enabled,
517 has_custom_headers: !self.config.custom_headers.is_empty(),
518 chunked_request_bodies: self.config.chunked_request_bodies,
519 target_rps: self.config.target_rps,
520 no_keep_alive: self.config.no_keep_alive,
521 duration_secs: self.config.duration_secs,
522 max_vus: self.config.max_vus,
523 start_vus: match self.config.scenario {
527 LoadScenario::Constant => self.config.max_vus,
528 _ => 0,
529 },
530 geo_source_ips: self.config.geo_source_ips.clone(),
538 geo_source_headers: self.config.geo_source_headers.clone(),
539 has_geo_source: !self.config.geo_source_ips.is_empty()
540 && !self.config.geo_source_headers.is_empty(),
541 geo_source_ips_json: serde_json::to_string(&self.config.geo_source_ips)
542 .unwrap_or_else(|_| "[]".to_string()),
543 geo_source_headers_json: serde_json::to_string(&self.config.geo_source_headers)
544 .unwrap_or_else(|_| "[]".to_string()),
545 force_http1: self.should_force_http1(),
546 })
547 }
548
549 fn build_headers_json(&self, template: &RequestTemplate) -> String {
551 let mut headers = template.get_headers();
552
553 if let Some(auth) = &self.config.auth_header {
555 headers.insert("Authorization".to_string(), auth.clone());
556 }
557
558 for (key, value) in &self.config.custom_headers {
560 headers.insert(key.clone(), value.clone());
561 }
562
563 if self.config.chunked_request_bodies && template.body.is_some() {
568 headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
569 }
570
571 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
573 }
574
575 pub fn validate_script(script: &str) -> Vec<String> {
584 let mut errors = Vec::new();
585
586 if !script.contains("import http from 'k6/http'") {
588 errors.push("Missing required import: 'k6/http'".to_string());
589 }
590 if !script.contains("import { check") && !script.contains("import {check") {
591 errors.push("Missing required import: 'check' from 'k6'".to_string());
592 }
593 if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
594 errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
595 }
596
597 let lines: Vec<&str> = script.lines().collect();
601 let mut seen_metric_consts: HashSet<String> = HashSet::new();
602 for (line_num, line) in lines.iter().enumerate() {
603 let trimmed = line.trim();
604
605 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
607 if let Some(name) = trimmed
611 .strip_prefix("const ")
612 .and_then(|rest| rest.split('=').next())
613 .map(str::trim)
614 .filter(|n| {
615 !n.is_empty() && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
616 })
617 {
618 if !seen_metric_consts.insert(name.to_string()) {
619 errors.push(format!(
620 "Line {}: duplicate const '{name}'. k6 exits 107 (ScriptException) when two traffic cases sanitize to the same identifier.",
621 line_num + 1
622 ));
623 }
624 }
625 if let Some(start) = trimmed.find('\'') {
628 if let Some(end) = trimmed[start + 1..].find('\'') {
629 let metric_name = &trimmed[start + 1..start + 1 + end];
630 if !Self::is_valid_k6_metric_name(metric_name) {
631 errors.push(format!(
632 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
633 line_num + 1,
634 metric_name
635 ));
636 }
637 }
638 } else if let Some(start) = trimmed.find('"') {
639 if let Some(end) = trimmed[start + 1..].find('"') {
640 let metric_name = &trimmed[start + 1..start + 1 + end];
641 if !Self::is_valid_k6_metric_name(metric_name) {
642 errors.push(format!(
643 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
644 line_num + 1,
645 metric_name
646 ));
647 }
648 }
649 }
650 }
651
652 if !trimmed.starts_with("//") {
659 if let Some(col) = Self::invalid_js_hex_escape_column(trimmed) {
660 errors.push(format!(
661 "Line {}:{}: invalid JS hex escape \\x (k6 requires two hex digits). Static paths must be JSON-encoded, not dumped into a template literal.",
662 line_num + 1,
663 col + 1
664 ));
665 }
666 }
667
668 if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
670 if let Some(equals_pos) = trimmed.find('=') {
671 let var_decl = &trimmed[..equals_pos];
672 if var_decl.contains('.')
675 && !var_decl.contains("'")
676 && !var_decl.contains("\"")
677 && !var_decl.trim().starts_with("//")
678 {
679 errors.push(format!(
680 "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
681 line_num + 1,
682 var_decl.trim()
683 ));
684 }
685 }
686 }
687 }
688
689 errors
690 }
691
692 fn invalid_js_hex_escape_column(line: &str) -> Option<usize> {
699 let bytes = line.as_bytes();
700 let mut i = 0;
701 while i + 1 < bytes.len() {
702 if bytes[i] == b'\\' && bytes[i + 1] == b'x' {
703 let mut preceding = 0usize;
704 let mut j = i;
705 while j > 0 && bytes[j - 1] == b'\\' {
706 preceding += 1;
707 j -= 1;
708 }
709 if preceding.is_multiple_of(2) {
712 let hex_ok = i + 3 < bytes.len()
713 && bytes[i + 2].is_ascii_hexdigit()
714 && bytes[i + 3].is_ascii_hexdigit();
715 if !hex_ok {
716 return Some(i);
717 }
718 }
719 }
720 i += 1;
721 }
722 None
723 }
724
725 fn is_valid_k6_metric_name(name: &str) -> bool {
732 if name.is_empty() || name.len() > 128 {
733 return false;
734 }
735
736 let mut chars = name.chars();
737
738 if let Some(first) = chars.next() {
740 if !first.is_ascii_alphabetic() && first != '_' {
741 return false;
742 }
743 }
744
745 for ch in chars {
747 if !ch.is_ascii_alphanumeric() && ch != '_' {
748 return false;
749 }
750 }
751
752 true
753 }
754}
755
756fn join_base_path(base_path: &str, raw_path: &str) -> String {
762 match base_path {
763 "" | "/" => raw_path.to_string(),
764 bp => {
765 let bp = bp.trim_end_matches('/');
766 if raw_path.starts_with('/') {
767 format!("{}{}", bp, raw_path)
768 } else {
769 format!("{}/{}", bp, raw_path)
770 }
771 }
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778
779 #[test]
780 fn root_base_path_does_not_double_slash() {
781 assert_eq!(
782 join_base_path(
783 "/",
784 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
785 ),
786 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
787 );
788 assert_eq!(join_base_path("", "/pets"), "/pets");
789 assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
790 assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
791 }
792
793 #[test]
794 fn test_k6_config_creation() {
795 let config = K6Config {
796 target_url: "https://api.example.com".to_string(),
797 base_path: None,
798 scenario: LoadScenario::RampUp,
799 duration_secs: 60,
800 max_vus: 10,
801 threshold_percentile: "p(95)".to_string(),
802 threshold_ms: 500,
803 max_error_rate: 0.05,
804 auth_header: None,
805 custom_headers: HashMap::new(),
806 skip_tls_verify: false,
807 security_testing_enabled: false,
808 chunked_request_bodies: false,
809 target_rps: None,
810 no_keep_alive: false,
811 geo_source_ips: Vec::new(),
812 geo_source_headers: Vec::new(),
813 };
814
815 assert_eq!(config.duration_secs, 60);
816 assert_eq!(config.max_vus, 10);
817 }
818
819 #[test]
820 fn test_script_generator_creation() {
821 let config = K6Config {
822 target_url: "https://api.example.com".to_string(),
823 base_path: None,
824 scenario: LoadScenario::Constant,
825 duration_secs: 30,
826 max_vus: 5,
827 threshold_percentile: "p(95)".to_string(),
828 threshold_ms: 500,
829 max_error_rate: 0.05,
830 auth_header: None,
831 custom_headers: HashMap::new(),
832 skip_tls_verify: false,
833 security_testing_enabled: false,
834 chunked_request_bodies: false,
835 target_rps: None,
836 no_keep_alive: false,
837 geo_source_ips: Vec::new(),
838 geo_source_headers: Vec::new(),
839 };
840
841 let templates = vec![];
842 let generator = K6ScriptGenerator::new(config, templates);
843
844 assert_eq!(generator.templates.len(), 0);
845 }
846
847 #[test]
848 fn colliding_operation_titles_get_unique_const_names() {
849 use crate::spec_parser::ApiOperation;
853 use openapiv3::Operation;
854
855 fn tmpl(id: &str, path: &str) -> RequestTemplate {
856 RequestTemplate {
857 operation: ApiOperation {
858 method: "get".to_string(),
859 path: path.to_string(),
860 operation: Operation::default(),
861 operation_id: Some(id.to_string()),
862 },
863 path_params: HashMap::new(),
864 query_params: HashMap::new(),
865 headers: HashMap::new(),
866 body: None,
867 }
868 }
869
870 let config = K6Config {
871 target_url: "https://example.test".to_string(),
872 base_path: None,
873 scenario: LoadScenario::Constant,
874 duration_secs: 5,
875 max_vus: 1,
876 threshold_percentile: "p(95)".to_string(),
877 threshold_ms: 500,
878 max_error_rate: 0.05,
879 auth_header: None,
880 custom_headers: HashMap::new(),
881 skip_tls_verify: false,
882 security_testing_enabled: false,
883 chunked_request_bodies: false,
884 target_rps: None,
885 no_keep_alive: false,
886 geo_source_ips: Vec::new(),
887 geo_source_headers: Vec::new(),
888 };
889 let generator = K6ScriptGenerator::new(
890 config,
891 vec![
892 tmpl("normal request allowed", "/a"),
893 tmpl("normal request allowed", "/b"),
894 ],
895 );
896 let script = generator.generate().expect("script generates");
897 let latency = script
898 .lines()
899 .filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
900 .collect::<Vec<_>>();
901 assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
902 assert!(
903 script.contains("const normal_request_allowed_latency = new Trend"),
904 "first collision keeps the base name"
905 );
906 assert!(
907 script.contains("const normal_request_allowed_2_latency = new Trend")
908 || script.contains("const normal_request_allowed_latency_2 = new Trend"),
909 "second collision must be renamed, script snippet:\n{}",
910 latency.join("\n")
911 );
912 let errors = K6ScriptGenerator::validate_script(&script);
913 assert!(errors.is_empty(), "validate_script: {errors:#?}");
914 }
915
916 #[test]
917 fn werkzeug_unc_backslash_x_is_json_encoded_not_template_literal() {
918 use crate::spec_parser::ApiOperation;
923 use openapiv3::Operation;
924
925 let path = "/static/\\\\attacker.com\\share\\x";
926 let template = RequestTemplate {
927 operation: ApiOperation {
928 method: "get".to_string(),
929 path: path.to_string(),
930 operation: Operation::default(),
931 operation_id: Some("literal UNC double-backslash path blocked".to_string()),
932 },
933 path_params: HashMap::new(),
934 query_params: HashMap::new(),
935 headers: HashMap::new(),
936 body: None,
937 };
938 let config = K6Config {
939 target_url: "https://example.test".to_string(),
940 base_path: None,
941 scenario: LoadScenario::Constant,
942 duration_secs: 5,
943 max_vus: 1,
944 threshold_percentile: "p(95)".to_string(),
945 threshold_ms: 500,
946 max_error_rate: 0.05,
947 auth_header: None,
948 custom_headers: HashMap::new(),
949 skip_tls_verify: false,
950 security_testing_enabled: false,
951 chunked_request_bodies: false,
952 target_rps: None,
953 no_keep_alive: false,
954 geo_source_ips: Vec::new(),
955 geo_source_headers: Vec::new(),
956 };
957 let script = K6ScriptGenerator::new(config, vec![template])
958 .generate()
959 .expect("script generates");
960 let encoded = serde_json::to_string(path).expect("path JSON");
961 assert!(
962 script.contains(&format!("BASE_URL + {encoded}")),
963 "expected BASE_URL + {encoded} in script:\n{script}"
964 );
965 assert!(
966 !script.contains("${BASE_URL}/static/"),
967 "must not dump the raw path into a template literal:\n{script}"
968 );
969 let errors = K6ScriptGenerator::validate_script(&script);
970 assert!(errors.is_empty(), "validate_script: {errors:#?}\n{script}");
971 }
972
973 #[test]
974 fn validate_script_flags_bare_hex_escape_in_template_literal() {
975 let bad = r#"
978import http from 'k6/http';
979import { check, sleep } from 'k6';
980import { Rate, Trend } from 'k6/metrics';
981const t_latency = new Trend('t_latency');
982export default function() {
983 const res = http.get(`${BASE_URL}/static/\\attacker.com\share\x`);
984}
985"#;
986 let errors = K6ScriptGenerator::validate_script(bad);
987 assert!(
988 errors.iter().any(|e| e.contains("invalid JS hex escape")),
989 "expected hex-escape error, got {errors:#?}"
990 );
991 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
992 r#"http.get(`${BASE_URL}/static/\\attacker.com\share\x`)"#
993 )
994 .is_some());
995 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
996 r#"BASE_URL + "/static/\\\\attacker.com\\share\\x""#
997 )
998 .is_none());
999 }
1000
1001 #[test]
1002 fn test_sanitize_js_identifier() {
1003 assert_eq!(
1005 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
1006 "billing_subscriptions_v1"
1007 );
1008
1009 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
1011
1012 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
1014
1015 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
1017
1018 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
1020
1021 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
1023
1024 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
1026
1027 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
1029 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
1030 assert_eq!(
1031 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
1032 "plans_update_pricing_schemes"
1033 );
1034 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
1035 }
1036
1037 #[test]
1038 fn test_sanitize_k6_metric_name_short_passthrough() {
1039 let short = "billing_subscriptions_list";
1041 let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
1042 assert_eq!(out, short);
1043 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
1044 }
1045
1046 #[test]
1047 fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
1048 let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
1052 charts.workbookChart.axes.categoryAxis.format.line.clear";
1053 let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
1054
1055 assert!(
1057 metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
1058 "metric base len {} exceeded cap {}",
1059 metric.len(),
1060 K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
1061 );
1062
1063 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1065 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
1066 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
1067 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
1069 }
1070
1071 #[test]
1072 fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
1073 let prefix = "a".repeat(150);
1076 let a = format!("{prefix}.foo");
1077 let b = format!("{prefix}.bar");
1078 let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
1079 let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
1080 assert_ne!(ma, mb, "distinct long names produced the same metric name");
1081 }
1082
1083 #[test]
1084 fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
1085 let long = format!("{}123end", "x".repeat(120));
1087 let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
1088 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1089 }
1090
1091 #[test]
1092 fn test_microsoft_graph_long_operation_id_passes_validation() {
1093 use crate::spec_parser::ApiOperation;
1096 use openapiv3::Operation;
1097
1098 let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
1099 workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
1100 line.clear";
1101
1102 let operation = ApiOperation {
1103 method: "post".to_string(),
1104 path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
1105 operation: Operation::default(),
1106 operation_id: Some(long_op_id.to_string()),
1107 };
1108 let template = RequestTemplate {
1109 operation,
1110 path_params: HashMap::new(),
1111 query_params: HashMap::new(),
1112 headers: HashMap::new(),
1113 body: None,
1114 };
1115 let config = K6Config {
1116 target_url: "https://api.example.com".to_string(),
1117 base_path: Some("/v1.0".to_string()),
1118 scenario: LoadScenario::Constant,
1119 duration_secs: 30,
1120 max_vus: 5,
1121 threshold_percentile: "p(95)".to_string(),
1122 threshold_ms: 500,
1123 max_error_rate: 0.05,
1124 auth_header: None,
1125 custom_headers: HashMap::new(),
1126 skip_tls_verify: false,
1127 security_testing_enabled: false,
1128 chunked_request_bodies: false,
1129 target_rps: None,
1130 no_keep_alive: false,
1131 geo_source_ips: Vec::new(),
1132 geo_source_headers: Vec::new(),
1133 };
1134 let generator = K6ScriptGenerator::new(config, vec![template]);
1135 let script = generator.generate().expect("script generates");
1136
1137 let errors = K6ScriptGenerator::validate_script(&script);
1138 assert!(
1139 errors.is_empty(),
1140 "validate_script returned errors for long operationId: {errors:#?}"
1141 );
1142 }
1143
1144 #[test]
1149 fn test_abort_valve_opt_out_and_rate() {
1150 fn base_config() -> K6Config {
1151 K6Config {
1152 target_url: "https://api.example.com".to_string(),
1153 base_path: None,
1154 scenario: LoadScenario::Constant,
1155 duration_secs: 30,
1156 max_vus: 5,
1157 threshold_percentile: "p(95)".to_string(),
1158 threshold_ms: 500,
1159 max_error_rate: 0.05,
1160 auth_header: None,
1161 custom_headers: HashMap::new(),
1162 skip_tls_verify: false,
1163 security_testing_enabled: false,
1164 chunked_request_bodies: false,
1165 target_rps: None,
1166 no_keep_alive: false,
1167 geo_source_ips: Vec::new(),
1168 geo_source_headers: Vec::new(),
1169 }
1170 }
1171
1172 let default_script = K6ScriptGenerator::new(base_config(), vec![])
1174 .generate()
1175 .expect("script generates");
1176 assert!(
1177 default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
1178 "default script must keep the 0.95 abort valve"
1179 );
1180
1181 let stress_script = K6ScriptGenerator::new(base_config(), vec![])
1184 .with_abort_valve(false, 0.95)
1185 .generate()
1186 .expect("script generates");
1187 assert!(
1190 !stress_script.contains("abortOnFail: true"),
1191 "--no-abort-on-error must drop the abortOnFail threshold"
1192 );
1193 assert!(stress_script.contains("rate<0.05"));
1195
1196 let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
1198 .with_abort_valve(true, 0.99)
1199 .generate()
1200 .expect("script generates");
1201 assert!(
1202 tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
1203 "--abort-on-error-rate must retune the valve threshold"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_script_generation_with_dots_in_name() {
1209 use crate::spec_parser::ApiOperation;
1210 use openapiv3::Operation;
1211
1212 let operation = ApiOperation {
1214 method: "get".to_string(),
1215 path: "/billing/subscriptions".to_string(),
1216 operation: Operation::default(),
1217 operation_id: Some("billing.subscriptions.v1".to_string()),
1218 };
1219
1220 let template = RequestTemplate {
1221 operation,
1222 path_params: HashMap::new(),
1223 query_params: HashMap::new(),
1224 headers: HashMap::new(),
1225 body: None,
1226 };
1227
1228 let config = K6Config {
1229 target_url: "https://api.example.com".to_string(),
1230 base_path: None,
1231 scenario: LoadScenario::Constant,
1232 duration_secs: 30,
1233 max_vus: 5,
1234 threshold_percentile: "p(95)".to_string(),
1235 threshold_ms: 500,
1236 max_error_rate: 0.05,
1237 auth_header: None,
1238 custom_headers: HashMap::new(),
1239 skip_tls_verify: false,
1240 security_testing_enabled: false,
1241 chunked_request_bodies: false,
1242 target_rps: None,
1243 no_keep_alive: false,
1244 geo_source_ips: Vec::new(),
1245 geo_source_headers: Vec::new(),
1246 };
1247
1248 let generator = K6ScriptGenerator::new(config, vec![template]);
1249 let script = generator.generate().expect("Should generate script");
1250
1251 assert!(
1253 script.contains("const billing_subscriptions_v1_latency"),
1254 "Script should contain sanitized variable name for latency"
1255 );
1256 assert!(
1257 script.contains("const billing_subscriptions_v1_errors"),
1258 "Script should contain sanitized variable name for errors"
1259 );
1260
1261 assert!(
1264 !script.contains("const billing.subscriptions"),
1265 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
1266 );
1267
1268 assert!(
1271 script.contains("'billing_subscriptions_v1_latency'"),
1272 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1273 );
1274 assert!(
1275 script.contains("'billing_subscriptions_v1_errors'"),
1276 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1277 );
1278
1279 assert!(
1281 script.contains("billing.subscriptions.v1"),
1282 "Script should contain original name in comments/strings for readability"
1283 );
1284
1285 assert!(
1287 script.contains("billing_subscriptions_v1_latency.add"),
1288 "Variable usage should use sanitized name"
1289 );
1290 assert!(
1291 script.contains("billing_subscriptions_v1_errors.add"),
1292 "Variable usage should use sanitized name"
1293 );
1294 }
1295
1296 #[test]
1303 fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
1304 use crate::spec_parser::ApiOperation;
1305 use openapiv3::Operation;
1306
1307 let operation = ApiOperation {
1308 method: "get".to_string(),
1309 path: "/users".to_string(),
1310 operation: Operation::default(),
1311 operation_id: Some("listUsers".to_string()),
1312 };
1313 let template = RequestTemplate {
1314 operation,
1315 path_params: HashMap::new(),
1316 query_params: HashMap::new(),
1317 headers: HashMap::new(),
1318 body: None,
1319 };
1320
1321 let config = K6Config {
1322 target_url: "https://api.example.com".to_string(),
1323 base_path: None,
1324 scenario: LoadScenario::RampUp,
1325 duration_secs: 600,
1326 max_vus: 100,
1327 threshold_percentile: "p(95)".to_string(),
1328 threshold_ms: 500,
1329 max_error_rate: 0.05,
1330 auth_header: None,
1331 custom_headers: HashMap::new(),
1332 skip_tls_verify: false,
1333 security_testing_enabled: false,
1334 chunked_request_bodies: false,
1335 target_rps: Some(100),
1336 no_keep_alive: false,
1337 geo_source_ips: Vec::new(),
1338 geo_source_headers: Vec::new(),
1339 };
1340
1341 let generator = K6ScriptGenerator::new(config, vec![template]);
1342 let script = generator.generate().expect("Should generate script");
1343
1344 assert!(
1345 script.contains("constant-arrival-rate"),
1346 "with --rps set, executor must switch to constant-arrival-rate"
1347 );
1348 assert!(
1349 script.contains("rate: 100,"),
1350 "constant-arrival-rate must use the configured --rps as `rate`"
1351 );
1352 assert!(
1353 script.contains("duration: '600s'"),
1354 "duration must come from --duration, not the ramp-down stage; got:\n{}",
1355 script
1356 );
1357 assert!(
1358 script.contains("preAllocatedVUs: 100,"),
1359 "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
1360 script
1361 );
1362 assert!(
1363 script.contains("maxVUs: 100,"),
1364 "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
1365 script
1366 );
1367 for (idx, line) in script.lines().enumerate() {
1371 let trimmed = line.trim_start();
1372 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1373 continue;
1374 }
1375 assert!(
1376 !trimmed.starts_with("preAllocatedVUs: 0"),
1377 "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
1378 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
1379 idx + 1,
1380 line,
1381 );
1382 }
1383 }
1384
1385 #[test]
1388 fn test_cps_sets_no_connection_reuse() {
1389 use crate::spec_parser::ApiOperation;
1390 use openapiv3::Operation;
1391
1392 let operation = ApiOperation {
1393 method: "get".to_string(),
1394 path: "/u".to_string(),
1395 operation: Operation::default(),
1396 operation_id: Some("u".to_string()),
1397 };
1398 let template = RequestTemplate {
1399 operation,
1400 path_params: HashMap::new(),
1401 query_params: HashMap::new(),
1402 headers: HashMap::new(),
1403 body: None,
1404 };
1405 let config = K6Config {
1406 target_url: "https://api.example.com".to_string(),
1407 base_path: None,
1408 scenario: LoadScenario::Constant,
1409 duration_secs: 30,
1410 max_vus: 5,
1411 threshold_percentile: "p(95)".to_string(),
1412 threshold_ms: 500,
1413 max_error_rate: 0.05,
1414 auth_header: None,
1415 custom_headers: HashMap::new(),
1416 skip_tls_verify: false,
1417 security_testing_enabled: false,
1418 chunked_request_bodies: false,
1419 target_rps: None,
1420 no_keep_alive: true,
1421 geo_source_ips: Vec::new(),
1422 geo_source_headers: Vec::new(),
1423 };
1424 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1425 assert!(
1426 script.contains("noConnectionReuse: true"),
1427 "--cps must set noConnectionReuse: true on the k6 options block"
1428 );
1429 assert!(
1430 script.contains("Total Connections:"),
1431 "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1432 );
1433 assert!(
1434 script.contains("Connection Rate:"),
1435 "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1436 );
1437 }
1438
1439 #[test]
1445 fn test_constant_scenario_starts_at_target_vus() {
1446 use crate::spec_parser::ApiOperation;
1447 use openapiv3::Operation;
1448
1449 let operation = ApiOperation {
1450 method: "get".to_string(),
1451 path: "/u".to_string(),
1452 operation: Operation::default(),
1453 operation_id: Some("u".to_string()),
1454 };
1455 let template = RequestTemplate {
1456 operation,
1457 path_params: HashMap::new(),
1458 query_params: HashMap::new(),
1459 headers: HashMap::new(),
1460 body: None,
1461 };
1462 let config = K6Config {
1463 target_url: "https://api.example.com".to_string(),
1464 base_path: None,
1465 scenario: LoadScenario::Constant,
1466 duration_secs: 600,
1467 max_vus: 5,
1468 threshold_percentile: "p(95)".to_string(),
1469 threshold_ms: 500,
1470 max_error_rate: 0.05,
1471 auth_header: None,
1472 custom_headers: HashMap::new(),
1473 skip_tls_verify: false,
1474 security_testing_enabled: false,
1475 chunked_request_bodies: false,
1476 target_rps: None,
1477 no_keep_alive: false,
1478 geo_source_ips: Vec::new(),
1479 geo_source_headers: Vec::new(),
1480 };
1481 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1482 assert!(
1483 script.contains("startVUs: 5,"),
1484 "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1485 script
1486 );
1487 let ramp_config = K6Config {
1489 target_url: "https://api.example.com".to_string(),
1490 base_path: None,
1491 scenario: LoadScenario::RampUp,
1492 duration_secs: 600,
1493 max_vus: 5,
1494 threshold_percentile: "p(95)".to_string(),
1495 threshold_ms: 500,
1496 max_error_rate: 0.05,
1497 auth_header: None,
1498 custom_headers: HashMap::new(),
1499 skip_tls_verify: false,
1500 security_testing_enabled: false,
1501 chunked_request_bodies: false,
1502 target_rps: None,
1503 no_keep_alive: false,
1504 geo_source_ips: Vec::new(),
1505 geo_source_headers: Vec::new(),
1506 };
1507 let ramp_template = RequestTemplate {
1508 operation: ApiOperation {
1509 method: "get".to_string(),
1510 path: "/u".to_string(),
1511 operation: Operation::default(),
1512 operation_id: Some("u".to_string()),
1513 },
1514 path_params: HashMap::new(),
1515 query_params: HashMap::new(),
1516 headers: HashMap::new(),
1517 body: None,
1518 };
1519 let ramp_script =
1520 K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1521 assert!(
1522 ramp_script.contains("startVUs: 0,"),
1523 "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1524 ramp_script
1525 );
1526 }
1527
1528 #[test]
1537 fn test_connections_opened_counter_present() {
1538 use crate::spec_parser::ApiOperation;
1539 use openapiv3::Operation;
1540
1541 let operation = ApiOperation {
1542 method: "get".to_string(),
1543 path: "/u".to_string(),
1544 operation: Operation::default(),
1545 operation_id: Some("u".to_string()),
1546 };
1547 let template = RequestTemplate {
1548 operation,
1549 path_params: HashMap::new(),
1550 query_params: HashMap::new(),
1551 headers: HashMap::new(),
1552 body: None,
1553 };
1554 let config = K6Config {
1555 target_url: "https://api.example.com".to_string(),
1556 base_path: None,
1557 scenario: LoadScenario::Constant,
1558 duration_secs: 30,
1559 max_vus: 5,
1560 threshold_percentile: "p(95)".to_string(),
1561 threshold_ms: 500,
1562 max_error_rate: 0.05,
1563 auth_header: None,
1564 custom_headers: HashMap::new(),
1565 skip_tls_verify: false,
1566 security_testing_enabled: false,
1567 chunked_request_bodies: false,
1568 target_rps: Some(50),
1569 no_keep_alive: false,
1570 geo_source_ips: Vec::new(),
1571 geo_source_headers: Vec::new(),
1572 };
1573 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1574 assert!(
1575 script.contains("new Counter('mockforge_connections_opened')"),
1576 "template must declare the mockforge_connections_opened Counter"
1577 );
1578 assert!(
1579 script.contains("mockforge_connections_opened.add(1)"),
1580 "template must increment mockforge_connections_opened on new TCP connect"
1581 );
1582 assert!(
1583 script.contains("res.timings.connecting > 0"),
1584 "template must gate the connection-opened increment on \
1585 res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_validate_script_valid() {
1591 let valid_script = r#"
1592import http from 'k6/http';
1593import { check, sleep } from 'k6';
1594import { Rate, Trend } from 'k6/metrics';
1595
1596const test_latency = new Trend('test_latency');
1597const test_errors = new Rate('test_errors');
1598
1599export default function() {
1600 const res = http.get('https://example.com');
1601 test_latency.add(res.timings.duration);
1602 test_errors.add(res.status !== 200);
1603}
1604"#;
1605
1606 let errors = K6ScriptGenerator::validate_script(valid_script);
1607 assert!(errors.is_empty(), "Valid script should have no validation errors");
1608 }
1609
1610 #[test]
1611 fn test_validate_script_invalid_metric_name() {
1612 let invalid_script = r#"
1613import http from 'k6/http';
1614import { check, sleep } from 'k6';
1615import { Rate, Trend } from 'k6/metrics';
1616
1617const test_latency = new Trend('test.latency');
1618const test_errors = new Rate('test_errors');
1619
1620export default function() {
1621 const res = http.get('https://example.com');
1622 test_latency.add(res.timings.duration);
1623}
1624"#;
1625
1626 let errors = K6ScriptGenerator::validate_script(invalid_script);
1627 assert!(
1628 !errors.is_empty(),
1629 "Script with invalid metric name should have validation errors"
1630 );
1631 assert!(
1632 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1633 "Should detect invalid metric name with dot"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_validate_script_missing_imports() {
1639 let invalid_script = r#"
1640const test_latency = new Trend('test_latency');
1641export default function() {}
1642"#;
1643
1644 let errors = K6ScriptGenerator::validate_script(invalid_script);
1645 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1646 }
1647
1648 #[test]
1649 fn test_validate_script_metric_name_validation() {
1650 let valid_script = r#"
1653import http from 'k6/http';
1654import { check, sleep } from 'k6';
1655import { Rate, Trend } from 'k6/metrics';
1656const test_latency = new Trend('test_latency');
1657const test_errors = new Rate('test_errors');
1658export default function() {}
1659"#;
1660 let errors = K6ScriptGenerator::validate_script(valid_script);
1661 assert!(errors.is_empty(), "Valid metric names should pass validation");
1662
1663 let invalid_cases = vec![
1665 ("test.latency", "dot in metric name"),
1666 ("123test", "starts with number"),
1667 ("test-latency", "hyphen in metric name"),
1668 ("test@latency", "special character"),
1669 ];
1670
1671 for (invalid_name, description) in invalid_cases {
1672 let script = format!(
1673 r#"
1674import http from 'k6/http';
1675import {{ check, sleep }} from 'k6';
1676import {{ Rate, Trend }} from 'k6/metrics';
1677const test_latency = new Trend('{}');
1678export default function() {{}}
1679"#,
1680 invalid_name
1681 );
1682 let errors = K6ScriptGenerator::validate_script(&script);
1683 assert!(
1684 !errors.is_empty(),
1685 "Metric name '{}' ({}) should fail validation",
1686 invalid_name,
1687 description
1688 );
1689 }
1690 }
1691
1692 #[test]
1693 fn test_skip_tls_verify_with_body() {
1694 use crate::spec_parser::ApiOperation;
1695 use openapiv3::Operation;
1696 use serde_json::json;
1697
1698 let operation = ApiOperation {
1700 method: "post".to_string(),
1701 path: "/api/users".to_string(),
1702 operation: Operation::default(),
1703 operation_id: Some("createUser".to_string()),
1704 };
1705
1706 let template = RequestTemplate {
1707 operation,
1708 path_params: HashMap::new(),
1709 query_params: HashMap::new(),
1710 headers: HashMap::new(),
1711 body: Some(json!({"name": "test"})),
1712 };
1713
1714 let config = K6Config {
1715 target_url: "https://api.example.com".to_string(),
1716 base_path: None,
1717 scenario: LoadScenario::Constant,
1718 duration_secs: 30,
1719 max_vus: 5,
1720 threshold_percentile: "p(95)".to_string(),
1721 threshold_ms: 500,
1722 max_error_rate: 0.05,
1723 auth_header: None,
1724 custom_headers: HashMap::new(),
1725 skip_tls_verify: true,
1726 security_testing_enabled: false,
1727 chunked_request_bodies: false,
1728 target_rps: None,
1729 no_keep_alive: false,
1730 geo_source_ips: Vec::new(),
1731 geo_source_headers: Vec::new(),
1732 };
1733
1734 let generator = K6ScriptGenerator::new(config, vec![template]);
1735 let script = generator.generate().expect("Should generate script");
1736
1737 assert!(
1739 script.contains("insecureSkipTLSVerify: true"),
1740 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1741 );
1742 }
1743
1744 #[test]
1745 fn test_skip_tls_verify_without_body() {
1746 use crate::spec_parser::ApiOperation;
1747 use openapiv3::Operation;
1748
1749 let operation = ApiOperation {
1751 method: "get".to_string(),
1752 path: "/api/users".to_string(),
1753 operation: Operation::default(),
1754 operation_id: Some("getUsers".to_string()),
1755 };
1756
1757 let template = RequestTemplate {
1758 operation,
1759 path_params: HashMap::new(),
1760 query_params: HashMap::new(),
1761 headers: HashMap::new(),
1762 body: None,
1763 };
1764
1765 let config = K6Config {
1766 target_url: "https://api.example.com".to_string(),
1767 base_path: None,
1768 scenario: LoadScenario::Constant,
1769 duration_secs: 30,
1770 max_vus: 5,
1771 threshold_percentile: "p(95)".to_string(),
1772 threshold_ms: 500,
1773 max_error_rate: 0.05,
1774 auth_header: None,
1775 custom_headers: HashMap::new(),
1776 skip_tls_verify: true,
1777 security_testing_enabled: false,
1778 chunked_request_bodies: false,
1779 target_rps: None,
1780 no_keep_alive: false,
1781 geo_source_ips: Vec::new(),
1782 geo_source_headers: Vec::new(),
1783 };
1784
1785 let generator = K6ScriptGenerator::new(config, vec![template]);
1786 let script = generator.generate().expect("Should generate script");
1787
1788 assert!(
1790 script.contains("insecureSkipTLSVerify: true"),
1791 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_no_skip_tls_verify() {
1797 use crate::spec_parser::ApiOperation;
1798 use openapiv3::Operation;
1799
1800 let operation = ApiOperation {
1802 method: "get".to_string(),
1803 path: "/api/users".to_string(),
1804 operation: Operation::default(),
1805 operation_id: Some("getUsers".to_string()),
1806 };
1807
1808 let template = RequestTemplate {
1809 operation,
1810 path_params: HashMap::new(),
1811 query_params: HashMap::new(),
1812 headers: HashMap::new(),
1813 body: None,
1814 };
1815
1816 let config = K6Config {
1817 target_url: "https://api.example.com".to_string(),
1818 base_path: None,
1819 scenario: LoadScenario::Constant,
1820 duration_secs: 30,
1821 max_vus: 5,
1822 threshold_percentile: "p(95)".to_string(),
1823 threshold_ms: 500,
1824 max_error_rate: 0.05,
1825 auth_header: None,
1826 custom_headers: HashMap::new(),
1827 skip_tls_verify: false,
1828 security_testing_enabled: false,
1829 chunked_request_bodies: false,
1830 target_rps: None,
1831 no_keep_alive: false,
1832 geo_source_ips: Vec::new(),
1833 geo_source_headers: Vec::new(),
1834 };
1835
1836 let generator = K6ScriptGenerator::new(config, vec![template]);
1837 let script = generator.generate().expect("Should generate script");
1838
1839 assert!(
1841 !script.contains("insecureSkipTLSVerify"),
1842 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1843 );
1844 }
1845
1846 #[test]
1847 fn test_skip_tls_verify_multiple_operations() {
1848 use crate::spec_parser::ApiOperation;
1849 use openapiv3::Operation;
1850 use serde_json::json;
1851
1852 let operation1 = ApiOperation {
1854 method: "get".to_string(),
1855 path: "/api/users".to_string(),
1856 operation: Operation::default(),
1857 operation_id: Some("getUsers".to_string()),
1858 };
1859
1860 let operation2 = ApiOperation {
1861 method: "post".to_string(),
1862 path: "/api/users".to_string(),
1863 operation: Operation::default(),
1864 operation_id: Some("createUser".to_string()),
1865 };
1866
1867 let template1 = RequestTemplate {
1868 operation: operation1,
1869 path_params: HashMap::new(),
1870 query_params: HashMap::new(),
1871 headers: HashMap::new(),
1872 body: None,
1873 };
1874
1875 let template2 = RequestTemplate {
1876 operation: operation2,
1877 path_params: HashMap::new(),
1878 query_params: HashMap::new(),
1879 headers: HashMap::new(),
1880 body: Some(json!({"name": "test"})),
1881 };
1882
1883 let config = K6Config {
1884 target_url: "https://api.example.com".to_string(),
1885 base_path: None,
1886 scenario: LoadScenario::Constant,
1887 duration_secs: 30,
1888 max_vus: 5,
1889 threshold_percentile: "p(95)".to_string(),
1890 threshold_ms: 500,
1891 max_error_rate: 0.05,
1892 auth_header: None,
1893 custom_headers: HashMap::new(),
1894 skip_tls_verify: true,
1895 security_testing_enabled: false,
1896 chunked_request_bodies: false,
1897 target_rps: None,
1898 no_keep_alive: false,
1899 geo_source_ips: Vec::new(),
1900 geo_source_headers: Vec::new(),
1901 };
1902
1903 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
1904 let script = generator.generate().expect("Should generate script");
1905
1906 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
1909 assert_eq!(
1910 skip_count, 1,
1911 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
1912 );
1913
1914 let options_start = script.find("export const options = {").expect("Should have options");
1916 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
1917 let options_prefix = &script[options_start..scenarios_start];
1918 assert!(
1919 options_prefix.contains("insecureSkipTLSVerify: true"),
1920 "insecureSkipTLSVerify should be in global options block"
1921 );
1922 }
1923
1924 #[test]
1925 fn test_dynamic_params_in_body() {
1926 use crate::spec_parser::ApiOperation;
1927 use openapiv3::Operation;
1928 use serde_json::json;
1929
1930 let operation = ApiOperation {
1932 method: "post".to_string(),
1933 path: "/api/resources".to_string(),
1934 operation: Operation::default(),
1935 operation_id: Some("createResource".to_string()),
1936 };
1937
1938 let template = RequestTemplate {
1939 operation,
1940 path_params: HashMap::new(),
1941 query_params: HashMap::new(),
1942 headers: HashMap::new(),
1943 body: Some(json!({
1944 "name": "load-test-${__VU}",
1945 "iteration": "${__ITER}"
1946 })),
1947 };
1948
1949 let config = K6Config {
1950 target_url: "https://api.example.com".to_string(),
1951 base_path: None,
1952 scenario: LoadScenario::Constant,
1953 duration_secs: 30,
1954 max_vus: 5,
1955 threshold_percentile: "p(95)".to_string(),
1956 threshold_ms: 500,
1957 max_error_rate: 0.05,
1958 auth_header: None,
1959 custom_headers: HashMap::new(),
1960 skip_tls_verify: false,
1961 security_testing_enabled: false,
1962 chunked_request_bodies: false,
1963 target_rps: None,
1964 no_keep_alive: false,
1965 geo_source_ips: Vec::new(),
1966 geo_source_headers: Vec::new(),
1967 };
1968
1969 let generator = K6ScriptGenerator::new(config, vec![template]);
1970 let script = generator.generate().expect("Should generate script");
1971
1972 assert!(
1974 script.contains("Dynamic body with runtime placeholders"),
1975 "Script should contain comment about dynamic body"
1976 );
1977
1978 assert!(
1980 script.contains("__VU"),
1981 "Script should contain __VU reference for dynamic VU-based values"
1982 );
1983
1984 assert!(
1986 script.contains("__ITER"),
1987 "Script should contain __ITER reference for dynamic iteration values"
1988 );
1989 }
1990
1991 #[test]
1992 fn test_dynamic_params_with_uuid() {
1993 use crate::spec_parser::ApiOperation;
1994 use openapiv3::Operation;
1995 use serde_json::json;
1996
1997 let operation = ApiOperation {
1999 method: "post".to_string(),
2000 path: "/api/resources".to_string(),
2001 operation: Operation::default(),
2002 operation_id: Some("createResource".to_string()),
2003 };
2004
2005 let template = RequestTemplate {
2006 operation,
2007 path_params: HashMap::new(),
2008 query_params: HashMap::new(),
2009 headers: HashMap::new(),
2010 body: Some(json!({
2011 "id": "${__UUID}"
2012 })),
2013 };
2014
2015 let config = K6Config {
2016 target_url: "https://api.example.com".to_string(),
2017 base_path: None,
2018 scenario: LoadScenario::Constant,
2019 duration_secs: 30,
2020 max_vus: 5,
2021 threshold_percentile: "p(95)".to_string(),
2022 threshold_ms: 500,
2023 max_error_rate: 0.05,
2024 auth_header: None,
2025 custom_headers: HashMap::new(),
2026 skip_tls_verify: false,
2027 security_testing_enabled: false,
2028 chunked_request_bodies: false,
2029 target_rps: None,
2030 no_keep_alive: false,
2031 geo_source_ips: Vec::new(),
2032 geo_source_headers: Vec::new(),
2033 };
2034
2035 let generator = K6ScriptGenerator::new(config, vec![template]);
2036 let script = generator.generate().expect("Should generate script");
2037
2038 assert!(
2041 !script.contains("k6/experimental/webcrypto"),
2042 "Script should NOT include deprecated k6/experimental/webcrypto import"
2043 );
2044
2045 assert!(
2047 script.contains("crypto.randomUUID()"),
2048 "Script should contain crypto.randomUUID() for UUID placeholder"
2049 );
2050 }
2051
2052 #[test]
2053 fn test_dynamic_params_with_counter() {
2054 use crate::spec_parser::ApiOperation;
2055 use openapiv3::Operation;
2056 use serde_json::json;
2057
2058 let operation = ApiOperation {
2060 method: "post".to_string(),
2061 path: "/api/resources".to_string(),
2062 operation: Operation::default(),
2063 operation_id: Some("createResource".to_string()),
2064 };
2065
2066 let template = RequestTemplate {
2067 operation,
2068 path_params: HashMap::new(),
2069 query_params: HashMap::new(),
2070 headers: HashMap::new(),
2071 body: Some(json!({
2072 "sequence": "${__COUNTER}"
2073 })),
2074 };
2075
2076 let config = K6Config {
2077 target_url: "https://api.example.com".to_string(),
2078 base_path: None,
2079 scenario: LoadScenario::Constant,
2080 duration_secs: 30,
2081 max_vus: 5,
2082 threshold_percentile: "p(95)".to_string(),
2083 threshold_ms: 500,
2084 max_error_rate: 0.05,
2085 auth_header: None,
2086 custom_headers: HashMap::new(),
2087 skip_tls_verify: false,
2088 security_testing_enabled: false,
2089 chunked_request_bodies: false,
2090 target_rps: None,
2091 no_keep_alive: false,
2092 geo_source_ips: Vec::new(),
2093 geo_source_headers: Vec::new(),
2094 };
2095
2096 let generator = K6ScriptGenerator::new(config, vec![template]);
2097 let script = generator.generate().expect("Should generate script");
2098
2099 assert!(
2101 script.contains("let globalCounter = 0"),
2102 "Script should include globalCounter initialization when COUNTER placeholder is used"
2103 );
2104
2105 assert!(
2107 script.contains("globalCounter++"),
2108 "Script should contain globalCounter++ for COUNTER placeholder"
2109 );
2110 }
2111
2112 #[test]
2113 fn test_static_body_no_dynamic_marker() {
2114 use crate::spec_parser::ApiOperation;
2115 use openapiv3::Operation;
2116 use serde_json::json;
2117
2118 let operation = ApiOperation {
2120 method: "post".to_string(),
2121 path: "/api/resources".to_string(),
2122 operation: Operation::default(),
2123 operation_id: Some("createResource".to_string()),
2124 };
2125
2126 let template = RequestTemplate {
2127 operation,
2128 path_params: HashMap::new(),
2129 query_params: HashMap::new(),
2130 headers: HashMap::new(),
2131 body: Some(json!({
2132 "name": "static-value",
2133 "count": 42
2134 })),
2135 };
2136
2137 let config = K6Config {
2138 target_url: "https://api.example.com".to_string(),
2139 base_path: None,
2140 scenario: LoadScenario::Constant,
2141 duration_secs: 30,
2142 max_vus: 5,
2143 threshold_percentile: "p(95)".to_string(),
2144 threshold_ms: 500,
2145 max_error_rate: 0.05,
2146 auth_header: None,
2147 custom_headers: HashMap::new(),
2148 skip_tls_verify: false,
2149 security_testing_enabled: false,
2150 chunked_request_bodies: false,
2151 target_rps: None,
2152 no_keep_alive: false,
2153 geo_source_ips: Vec::new(),
2154 geo_source_headers: Vec::new(),
2155 };
2156
2157 let generator = K6ScriptGenerator::new(config, vec![template]);
2158 let script = generator.generate().expect("Should generate script");
2159
2160 assert!(
2162 !script.contains("Dynamic body with runtime placeholders"),
2163 "Script should NOT contain dynamic body comment for static body"
2164 );
2165
2166 assert!(
2168 !script.contains("webcrypto"),
2169 "Script should NOT include webcrypto import for static body"
2170 );
2171
2172 assert!(
2174 !script.contains("let globalCounter"),
2175 "Script should NOT include globalCounter for static body"
2176 );
2177 }
2178
2179 #[test]
2180 fn test_security_testing_enabled_generates_calling_code() {
2181 use crate::spec_parser::ApiOperation;
2182 use openapiv3::Operation;
2183 use serde_json::json;
2184
2185 let operation = ApiOperation {
2186 method: "post".to_string(),
2187 path: "/api/users".to_string(),
2188 operation: Operation::default(),
2189 operation_id: Some("createUser".to_string()),
2190 };
2191
2192 let template = RequestTemplate {
2193 operation,
2194 path_params: HashMap::new(),
2195 query_params: HashMap::new(),
2196 headers: HashMap::new(),
2197 body: Some(json!({"name": "test"})),
2198 };
2199
2200 let config = K6Config {
2201 target_url: "https://api.example.com".to_string(),
2202 base_path: None,
2203 scenario: LoadScenario::Constant,
2204 duration_secs: 30,
2205 max_vus: 5,
2206 threshold_percentile: "p(95)".to_string(),
2207 threshold_ms: 500,
2208 max_error_rate: 0.05,
2209 auth_header: None,
2210 custom_headers: HashMap::new(),
2211 skip_tls_verify: false,
2212 security_testing_enabled: true,
2213 chunked_request_bodies: false,
2214 target_rps: None,
2215 no_keep_alive: false,
2216 geo_source_ips: Vec::new(),
2217 geo_source_headers: Vec::new(),
2218 };
2219
2220 let generator = K6ScriptGenerator::new(config, vec![template]);
2221 let script = generator.generate().expect("Should generate script");
2222
2223 assert!(
2225 script.contains("getNextSecurityPayload"),
2226 "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
2227 );
2228 assert!(
2229 script.contains("applySecurityPayload"),
2230 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
2231 );
2232 assert!(
2233 script.contains("secPayloadGroup"),
2234 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
2235 );
2236 assert!(
2237 script.contains("secBodyPayload"),
2238 "Script should contain secBodyPayload variable when security_testing_enabled is true"
2239 );
2240 assert!(
2242 script.contains("hasSecCookie"),
2243 "Script should track hasSecCookie for CookieJar conflict avoidance"
2244 );
2245 assert!(
2246 script.contains("secRequestOpts"),
2247 "Script should use secRequestOpts to conditionally skip CookieJar"
2248 );
2249 assert!(
2251 script.contains("const requestHeaders = { ..."),
2252 "Script should spread headers into mutable copy for security payload injection"
2253 );
2254 assert!(
2256 script.contains("secPayload.injectAsPath"),
2257 "Script should check injectAsPath for path-based URI injection"
2258 );
2259 assert!(
2261 script.contains("secBodyPayload.formBody"),
2262 "Script should check formBody for form-encoded body delivery"
2263 );
2264 assert!(
2265 script.contains("application/x-www-form-urlencoded"),
2266 "Script should set Content-Type for form-encoded body"
2267 );
2268 let op_comment_pos =
2270 script.find("// Operation 0:").expect("Should have Operation 0 comment");
2271 let sec_payload_pos = script
2272 .find("const secPayloadGroup = typeof getNextSecurityPayload")
2273 .expect("Should have secPayloadGroup assignment");
2274 assert!(
2275 sec_payload_pos > op_comment_pos,
2276 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_security_testing_disabled_no_calling_code() {
2282 use crate::spec_parser::ApiOperation;
2283 use openapiv3::Operation;
2284 use serde_json::json;
2285
2286 let operation = ApiOperation {
2287 method: "post".to_string(),
2288 path: "/api/users".to_string(),
2289 operation: Operation::default(),
2290 operation_id: Some("createUser".to_string()),
2291 };
2292
2293 let template = RequestTemplate {
2294 operation,
2295 path_params: HashMap::new(),
2296 query_params: HashMap::new(),
2297 headers: HashMap::new(),
2298 body: Some(json!({"name": "test"})),
2299 };
2300
2301 let config = K6Config {
2302 target_url: "https://api.example.com".to_string(),
2303 base_path: None,
2304 scenario: LoadScenario::Constant,
2305 duration_secs: 30,
2306 max_vus: 5,
2307 threshold_percentile: "p(95)".to_string(),
2308 threshold_ms: 500,
2309 max_error_rate: 0.05,
2310 auth_header: None,
2311 custom_headers: HashMap::new(),
2312 skip_tls_verify: false,
2313 security_testing_enabled: false,
2314 chunked_request_bodies: false,
2315 target_rps: None,
2316 no_keep_alive: false,
2317 geo_source_ips: Vec::new(),
2318 geo_source_headers: Vec::new(),
2319 };
2320
2321 let generator = K6ScriptGenerator::new(config, vec![template]);
2322 let script = generator.generate().expect("Should generate script");
2323
2324 assert!(
2326 !script.contains("getNextSecurityPayload"),
2327 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
2328 );
2329 assert!(
2330 !script.contains("applySecurityPayload"),
2331 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
2332 );
2333 assert!(
2334 !script.contains("secPayloadGroup"),
2335 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
2336 );
2337 assert!(
2338 !script.contains("secBodyPayload"),
2339 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
2340 );
2341 assert!(
2342 !script.contains("hasSecCookie"),
2343 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
2344 );
2345 assert!(
2346 !script.contains("secRequestOpts"),
2347 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
2348 );
2349 assert!(
2350 !script.contains("injectAsPath"),
2351 "Script should NOT contain injectAsPath when security_testing_enabled is false"
2352 );
2353 assert!(
2354 !script.contains("formBody"),
2355 "Script should NOT contain formBody when security_testing_enabled is false"
2356 );
2357 }
2358
2359 #[test]
2363 fn test_security_e2e_definitions_and_calls_both_present() {
2364 use crate::security_payloads::{
2365 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
2366 };
2367 use crate::spec_parser::ApiOperation;
2368 use openapiv3::Operation;
2369 use serde_json::json;
2370
2371 let operation = ApiOperation {
2373 method: "post".to_string(),
2374 path: "/api/users".to_string(),
2375 operation: Operation::default(),
2376 operation_id: Some("createUser".to_string()),
2377 };
2378
2379 let template = RequestTemplate {
2380 operation,
2381 path_params: HashMap::new(),
2382 query_params: HashMap::new(),
2383 headers: HashMap::new(),
2384 body: Some(json!({"name": "test"})),
2385 };
2386
2387 let config = K6Config {
2388 target_url: "https://api.example.com".to_string(),
2389 base_path: None,
2390 scenario: LoadScenario::Constant,
2391 duration_secs: 30,
2392 max_vus: 5,
2393 threshold_percentile: "p(95)".to_string(),
2394 threshold_ms: 500,
2395 max_error_rate: 0.05,
2396 auth_header: None,
2397 custom_headers: HashMap::new(),
2398 skip_tls_verify: false,
2399 security_testing_enabled: true,
2400 chunked_request_bodies: false,
2401 target_rps: None,
2402 no_keep_alive: false,
2403 geo_source_ips: Vec::new(),
2404 geo_source_headers: Vec::new(),
2405 };
2406
2407 let generator = K6ScriptGenerator::new(config, vec![template]);
2408 let mut script = generator.generate().expect("Should generate base script");
2409
2410 let security_config = SecurityTestConfig::default().enable();
2412 let payloads = SecurityPayloads::get_payloads(&security_config);
2413 assert!(!payloads.is_empty(), "Should have built-in payloads");
2414
2415 let mut additional_code = String::new();
2416 additional_code
2417 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
2418 additional_code.push('\n');
2419 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
2420 additional_code.push('\n');
2421
2422 if let Some(pos) = script.find("export const options") {
2424 script.insert_str(
2425 pos,
2426 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
2427 );
2428 }
2429
2430 assert!(
2433 script.contains("function getNextSecurityPayload()"),
2434 "Final script must contain getNextSecurityPayload function DEFINITION"
2435 );
2436 assert!(
2437 script.contains("function applySecurityPayload("),
2438 "Final script must contain applySecurityPayload function DEFINITION"
2439 );
2440 assert!(
2441 script.contains("securityPayloads"),
2442 "Final script must contain securityPayloads array"
2443 );
2444
2445 assert!(
2447 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2448 "Final script must contain secPayloadGroup assignment (template calling code)"
2449 );
2450 assert!(
2451 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2452 "Final script must contain applySecurityPayload CALL with secBodyPayload"
2453 );
2454 assert!(
2455 script.contains("const requestHeaders = { ..."),
2456 "Final script must spread headers for security payload header injection"
2457 );
2458 assert!(
2459 script.contains("for (const secPayload of secPayloadGroup)"),
2460 "Final script must loop over secPayloadGroup"
2461 );
2462 assert!(
2463 script.contains("secPayload.injectAsPath"),
2464 "Final script must check injectAsPath for path-based URI injection"
2465 );
2466 assert!(
2467 script.contains("secBodyPayload.formBody"),
2468 "Final script must check formBody for form-encoded body delivery"
2469 );
2470
2471 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2473 let call_pos =
2474 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2475 let options_pos = script.find("export const options").unwrap();
2476 let default_fn_pos = script.find("export default function").unwrap();
2477
2478 assert!(
2479 def_pos < options_pos,
2480 "Function definitions must appear before export const options"
2481 );
2482 assert!(
2483 call_pos > default_fn_pos,
2484 "Calling code must appear inside export default function"
2485 );
2486 }
2487
2488 #[test]
2490 fn test_security_uri_injection_for_get_requests() {
2491 use crate::spec_parser::ApiOperation;
2492 use openapiv3::Operation;
2493
2494 let operation = ApiOperation {
2495 method: "get".to_string(),
2496 path: "/api/users".to_string(),
2497 operation: Operation::default(),
2498 operation_id: Some("listUsers".to_string()),
2499 };
2500
2501 let template = RequestTemplate {
2502 operation,
2503 path_params: HashMap::new(),
2504 query_params: HashMap::new(),
2505 headers: HashMap::new(),
2506 body: None,
2507 };
2508
2509 let config = K6Config {
2510 target_url: "https://api.example.com".to_string(),
2511 base_path: None,
2512 scenario: LoadScenario::Constant,
2513 duration_secs: 30,
2514 max_vus: 5,
2515 threshold_percentile: "p(95)".to_string(),
2516 threshold_ms: 500,
2517 max_error_rate: 0.05,
2518 auth_header: None,
2519 custom_headers: HashMap::new(),
2520 skip_tls_verify: false,
2521 security_testing_enabled: true,
2522 chunked_request_bodies: false,
2523 target_rps: None,
2524 no_keep_alive: false,
2525 geo_source_ips: Vec::new(),
2526 geo_source_headers: Vec::new(),
2527 };
2528
2529 let generator = K6ScriptGenerator::new(config, vec![template]);
2530 let script = generator.generate().expect("Should generate script");
2531
2532 assert!(
2534 script.contains("requestUrl"),
2535 "Script should build requestUrl variable for URI payload injection"
2536 );
2537 assert!(
2538 script.contains("secPayload.location === 'uri'"),
2539 "Script should check for URI-location payloads"
2540 );
2541 assert!(
2543 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2544 "Script should URL-encode security payload in query string for valid HTTP"
2545 );
2546 assert!(
2548 script.contains("secPayload.injectAsPath"),
2549 "Script should check injectAsPath for path-based URI injection"
2550 );
2551 assert!(
2552 script.contains("encodeURI(secPayload.payload)"),
2553 "Script should use encodeURI for path-based injection"
2554 );
2555 assert!(
2557 script.contains("http.get(requestUrl,"),
2558 "GET request should use requestUrl (with URI injection) instead of inline URL"
2559 );
2560 }
2561
2562 #[test]
2564 fn test_security_uri_injection_for_post_requests() {
2565 use crate::spec_parser::ApiOperation;
2566 use openapiv3::Operation;
2567 use serde_json::json;
2568
2569 let operation = ApiOperation {
2570 method: "post".to_string(),
2571 path: "/api/users".to_string(),
2572 operation: Operation::default(),
2573 operation_id: Some("createUser".to_string()),
2574 };
2575
2576 let template = RequestTemplate {
2577 operation,
2578 path_params: HashMap::new(),
2579 query_params: HashMap::new(),
2580 headers: HashMap::new(),
2581 body: Some(json!({"name": "test"})),
2582 };
2583
2584 let config = K6Config {
2585 target_url: "https://api.example.com".to_string(),
2586 base_path: None,
2587 scenario: LoadScenario::Constant,
2588 duration_secs: 30,
2589 max_vus: 5,
2590 threshold_percentile: "p(95)".to_string(),
2591 threshold_ms: 500,
2592 max_error_rate: 0.05,
2593 auth_header: None,
2594 custom_headers: HashMap::new(),
2595 skip_tls_verify: false,
2596 security_testing_enabled: true,
2597 chunked_request_bodies: false,
2598 target_rps: None,
2599 no_keep_alive: false,
2600 geo_source_ips: Vec::new(),
2601 geo_source_headers: Vec::new(),
2602 };
2603
2604 let generator = K6ScriptGenerator::new(config, vec![template]);
2605 let script = generator.generate().expect("Should generate script");
2606
2607 assert!(
2609 script.contains("requestUrl"),
2610 "POST script should build requestUrl for URI payload injection"
2611 );
2612 assert!(
2613 script.contains("secPayload.location === 'uri'"),
2614 "POST script should check for URI-location payloads"
2615 );
2616 assert!(
2617 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2618 "POST script should apply security body payload to request body"
2619 );
2620 assert!(
2622 script.contains("http.post(requestUrl,"),
2623 "POST request should use requestUrl (with URI injection) instead of inline URL"
2624 );
2625 }
2626
2627 #[test]
2629 fn test_no_uri_injection_when_security_disabled() {
2630 use crate::spec_parser::ApiOperation;
2631 use openapiv3::Operation;
2632
2633 let operation = ApiOperation {
2634 method: "get".to_string(),
2635 path: "/api/users".to_string(),
2636 operation: Operation::default(),
2637 operation_id: Some("listUsers".to_string()),
2638 };
2639
2640 let template = RequestTemplate {
2641 operation,
2642 path_params: HashMap::new(),
2643 query_params: HashMap::new(),
2644 headers: HashMap::new(),
2645 body: None,
2646 };
2647
2648 let config = K6Config {
2649 target_url: "https://api.example.com".to_string(),
2650 base_path: None,
2651 scenario: LoadScenario::Constant,
2652 duration_secs: 30,
2653 max_vus: 5,
2654 threshold_percentile: "p(95)".to_string(),
2655 threshold_ms: 500,
2656 max_error_rate: 0.05,
2657 auth_header: None,
2658 custom_headers: HashMap::new(),
2659 skip_tls_verify: false,
2660 security_testing_enabled: false,
2661 chunked_request_bodies: false,
2662 target_rps: None,
2663 no_keep_alive: false,
2664 geo_source_ips: Vec::new(),
2665 geo_source_headers: Vec::new(),
2666 };
2667
2668 let generator = K6ScriptGenerator::new(config, vec![template]);
2669 let script = generator.generate().expect("Should generate script");
2670
2671 assert!(
2673 !script.contains("requestUrl"),
2674 "Script should NOT have requestUrl when security is disabled"
2675 );
2676 assert!(
2677 !script.contains("secPayloadGroup"),
2678 "Script should NOT have secPayloadGroup when security is disabled"
2679 );
2680 assert!(
2681 !script.contains("secBodyPayload"),
2682 "Script should NOT have secBodyPayload when security is disabled"
2683 );
2684 }
2685
2686 #[test]
2688 fn test_uses_per_request_cookie_jar() {
2689 use crate::spec_parser::ApiOperation;
2690 use openapiv3::Operation;
2691
2692 let operation = ApiOperation {
2693 method: "get".to_string(),
2694 path: "/api/users".to_string(),
2695 operation: Operation::default(),
2696 operation_id: Some("listUsers".to_string()),
2697 };
2698
2699 let template = RequestTemplate {
2700 operation,
2701 path_params: HashMap::new(),
2702 query_params: HashMap::new(),
2703 headers: HashMap::new(),
2704 body: None,
2705 };
2706
2707 let config = K6Config {
2708 target_url: "https://api.example.com".to_string(),
2709 base_path: None,
2710 scenario: LoadScenario::Constant,
2711 duration_secs: 30,
2712 max_vus: 5,
2713 threshold_percentile: "p(95)".to_string(),
2714 threshold_ms: 500,
2715 max_error_rate: 0.05,
2716 auth_header: None,
2717 custom_headers: HashMap::new(),
2718 skip_tls_verify: false,
2719 security_testing_enabled: false,
2720 chunked_request_bodies: false,
2721 target_rps: None,
2722 no_keep_alive: false,
2723 geo_source_ips: Vec::new(),
2724 geo_source_headers: Vec::new(),
2725 };
2726
2727 let generator = K6ScriptGenerator::new(config, vec![template]);
2728 let script = generator.generate().expect("Should generate script");
2729
2730 assert!(
2732 script.contains("jar: new http.CookieJar()"),
2733 "Script should create fresh CookieJar per request"
2734 );
2735 assert!(
2736 !script.contains("jar: null"),
2737 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2738 );
2739 assert!(
2740 !script.contains("EMPTY_JAR"),
2741 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2742 );
2743 }
2744
2745 #[test]
2749 fn connection_header_forces_http1_comment_and_stays_on_the_wire() {
2750 use crate::spec_parser::ApiOperation;
2751 use openapiv3::Operation;
2752
2753 let operation = ApiOperation {
2754 method: "get".to_string(),
2755 path: "/hop".to_string(),
2756 operation: Operation::default(),
2757 operation_id: Some("hop".to_string()),
2758 };
2759 let mut headers = HashMap::new();
2760 headers.insert("Connection".to_string(), "Transfer-Encoding, keep-alive".to_string());
2761 let template = RequestTemplate {
2762 operation,
2763 path_params: HashMap::new(),
2764 query_params: HashMap::new(),
2765 headers,
2766 body: None,
2767 };
2768 let config = K6Config {
2769 target_url: "https://waf.example.com".to_string(),
2770 base_path: None,
2771 scenario: LoadScenario::Constant,
2772 duration_secs: 30,
2773 max_vus: 1,
2774 threshold_percentile: "p(95)".to_string(),
2775 threshold_ms: 500,
2776 max_error_rate: 0.05,
2777 auth_header: None,
2778 custom_headers: HashMap::new(),
2779 skip_tls_verify: true,
2780 security_testing_enabled: false,
2781 chunked_request_bodies: false,
2782 target_rps: None,
2783 no_keep_alive: false,
2784 geo_source_ips: Vec::new(),
2785 geo_source_headers: Vec::new(),
2786 };
2787 let generator = K6ScriptGenerator::new(config, vec![template]);
2788 assert!(generator.should_force_http1());
2789 let data = generator.build_template_data().expect("template data");
2790 assert!(data.force_http1);
2791 let script = generator.generate().expect("script generates");
2792 assert!(
2793 script.contains("GODEBUG=http2client=0"),
2794 "script must tell a manual k6 run to disable HTTP/2"
2795 );
2796 assert!(
2797 script.contains("Transfer-Encoding, keep-alive"),
2798 "Connection value must stay in the script; stripping it skips the WAF case"
2799 );
2800 assert!(
2801 script.contains("\"Connection\"") || script.contains("Connection"),
2802 "Connection header key must stay on the wire"
2803 );
2804 }
2805
2806 #[test]
2809 fn verbatim_flag_forces_http1_comment_without_connection_header() {
2810 let config = K6Config {
2811 target_url: "https://waf.example.com".to_string(),
2812 base_path: None,
2813 scenario: LoadScenario::Constant,
2814 duration_secs: 30,
2815 max_vus: 1,
2816 threshold_percentile: "p(95)".to_string(),
2817 threshold_ms: 500,
2818 max_error_rate: 0.05,
2819 auth_header: None,
2820 custom_headers: HashMap::new(),
2821 skip_tls_verify: true,
2822 security_testing_enabled: false,
2823 chunked_request_bodies: false,
2824 target_rps: None,
2825 no_keep_alive: false,
2826 geo_source_ips: Vec::new(),
2827 geo_source_headers: Vec::new(),
2828 };
2829 let generator = K6ScriptGenerator::new(config, vec![]).with_force_http1(true);
2830 assert!(generator.should_force_http1());
2831 let script = generator.generate().expect("script generates");
2832 assert!(script.contains("GODEBUG=http2client=0"));
2833 }
2834}