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 pub per_op_metrics: bool,
112}
113
114#[derive(Debug, Clone, Serialize)]
116pub struct K6CrudFlowTemplateData {
117 pub base_url: String,
118 pub flows: Vec<Value>,
119 pub extract_fields: Vec<String>,
120 pub duration_secs: u64,
121 pub max_vus: u32,
122 pub auth_header: Option<String>,
123 pub custom_headers: HashMap<String, String>,
124 pub skip_tls_verify: bool,
125 pub stages: Vec<K6StageData>,
126 pub threshold_percentile: String,
127 pub threshold_ms: u64,
128 pub max_error_rate: f64,
129 pub headers: String,
131 pub dynamic_imports: Vec<String>,
132 pub dynamic_globals: Vec<String>,
133 pub extracted_values_output_path: String,
134 pub error_injection_enabled: bool,
135 pub error_rate: f64,
136 pub error_types: Vec<String>,
137 pub security_testing_enabled: bool,
138 pub has_custom_headers: bool,
139}
140
141#[derive(Debug, Clone, Serialize)]
143pub struct K6StageData {
144 pub duration: String,
145 pub target: u32,
146}
147
148#[derive(Debug, Clone, Serialize)]
150pub struct K6OperationData {
151 pub index: usize,
152 pub name: String,
153 pub metric_name: String,
154 pub display_name: String,
155 pub method: String,
156 pub path: Value,
157 pub path_is_dynamic: bool,
158 pub headers: Value,
159 pub body: Option<Value>,
160 pub body_is_dynamic: bool,
161 pub has_body: bool,
162 pub is_get_or_head: bool,
163}
164
165pub struct K6Config {
167 pub target_url: String,
168 pub base_path: Option<String>,
171 pub scenario: LoadScenario,
172 pub duration_secs: u64,
173 pub max_vus: u32,
174 pub threshold_percentile: String,
175 pub threshold_ms: u64,
176 pub max_error_rate: f64,
177 pub auth_header: Option<String>,
178 pub custom_headers: HashMap<String, String>,
179 pub skip_tls_verify: bool,
180 pub security_testing_enabled: bool,
181 pub chunked_request_bodies: bool,
184 pub target_rps: Option<u32>,
187 pub no_keep_alive: bool,
190 pub geo_source_ips: Vec<String>,
194 pub geo_source_headers: Vec<String>,
198}
199
200pub const PER_OP_METRICS_AUTO_OPS_THRESHOLD: usize = 500;
204
205pub const PER_OP_METRICS_AUTO_DURATION_SECS: u64 = 3600;
208
209pub const MAX_CONCURRENCY_DEFAULT: usize = 10;
211
212pub const MAX_CONCURRENCY_HUGE_SPEC: usize = 3;
215
216pub const HUGE_SPEC_OPS_THRESHOLD: usize = 500;
218
219pub fn resolve_per_op_metrics(
225 explicit: Option<bool>,
226 op_count: usize,
227 duration_secs: u64,
228) -> (bool, Option<String>) {
229 if let Some(force) = explicit {
230 return (force, None);
231 }
232 if op_count >= PER_OP_METRICS_AUTO_OPS_THRESHOLD {
233 return (
234 false,
235 Some(format!(
236 "Auto-disabled per-operation k6 metrics ({op_count} ops >= \
237 {PER_OP_METRICS_AUTO_OPS_THRESHOLD}). Huge metric sets grow RSS \
238 on long runs and can OOM (SIGKILL). Force on with --per-op-metrics; \
239 keep off with --no-per-op-metrics."
240 )),
241 );
242 }
243 if duration_secs >= PER_OP_METRICS_AUTO_DURATION_SECS {
244 return (
245 false,
246 Some(format!(
247 "Auto-disabled per-operation k6 metrics (duration {duration_secs}s >= \
248 {PER_OP_METRICS_AUTO_DURATION_SECS}s). Longevity runs accumulate metric \
249 samples until the OOM killer fires. Force on with --per-op-metrics."
250 )),
251 );
252 }
253 (true, None)
254}
255
256pub fn resolve_max_concurrency(
259 explicit: Option<usize>,
260 op_count: usize,
261 n_targets: usize,
262) -> (usize, Option<String>) {
263 let n_targets = n_targets.max(1);
264 if let Some(n) = explicit {
265 return (n.max(1).min(n_targets), None);
266 }
267 if op_count >= HUGE_SPEC_OPS_THRESHOLD {
268 let conc = MAX_CONCURRENCY_HUGE_SPEC.min(n_targets);
269 return (
270 conc,
271 Some(format!(
272 "Auto-capped --max-concurrency to {conc} ({op_count} ops >= \
273 {HUGE_SPEC_OPS_THRESHOLD}). Parallel heavyweight k6 scripts share \
274 RAM; override with --max-concurrency N."
275 )),
276 );
277 }
278 (MAX_CONCURRENCY_DEFAULT.min(n_targets), None)
279}
280
281pub struct K6ScriptGenerator {
283 config: K6Config,
284 templates: Vec<RequestTemplate>,
285 abort_on_error: bool,
288 abort_on_error_rate: f64,
291 force_http1: bool,
295 per_op_metrics: bool,
298}
299
300impl K6ScriptGenerator {
301 pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
307 Self {
308 config,
309 templates,
310 abort_on_error: true,
311 abort_on_error_rate: 0.95,
312 force_http1: false,
313 per_op_metrics: true,
316 }
317 }
318
319 #[must_use]
323 pub fn with_force_http1(mut self, force_http1: bool) -> Self {
324 self.force_http1 = force_http1;
325 self
326 }
327
328 #[must_use]
331 pub fn with_per_op_metrics(mut self, per_op_metrics: bool) -> Self {
332 self.per_op_metrics = per_op_metrics;
333 self
334 }
335
336 #[must_use]
344 pub fn with_abort_valve(mut self, abort_on_error: bool, abort_on_error_rate: f64) -> Self {
345 self.abort_on_error = abort_on_error;
346 self.abort_on_error_rate = abort_on_error_rate;
347 self
348 }
349
350 pub fn should_force_http1(&self) -> bool {
353 crate::request_gen::should_force_k6_http1(
354 self.force_http1,
355 &self.templates,
356 &self.config.custom_headers,
357 )
358 }
359
360 pub fn generate(&self) -> Result<String> {
362 let handlebars = Handlebars::new();
363
364 let template = include_str!("templates/k6_script.hbs");
365
366 let data = self.build_template_data()?;
367
368 let value = serde_json::to_value(&data)
369 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
370
371 handlebars
372 .render_template(template, &value)
373 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
374 }
375
376 const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
382
383 pub fn sanitize_k6_metric_name(name: &str) -> String {
396 let sanitized = Self::sanitize_js_identifier(name);
397 if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
398 return sanitized;
399 }
400
401 use std::collections::hash_map::DefaultHasher;
402 use std::hash::{Hash, Hasher};
403 let mut hasher = DefaultHasher::new();
404 name.hash(&mut hasher);
408 let hash_suffix = format!("{:08x}", hasher.finish() as u32);
409
410 let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
412 let prefix = &sanitized[..prefix_len];
413 let prefix = prefix.trim_end_matches('_');
415 format!("{}_{}", prefix, hash_suffix)
416 }
417
418 fn uniquify_name(base: String, used: &mut HashSet<String>) -> String {
426 if used.insert(base.clone()) {
427 return base;
428 }
429 let mut n = 2u32;
430 loop {
431 let candidate = format!("{base}_{n}");
432 if used.insert(candidate.clone()) {
433 return candidate;
434 }
435 n = n.saturating_add(1);
436 if n == u32::MAX {
437 use std::collections::hash_map::DefaultHasher;
438 use std::hash::{Hash, Hasher};
439 let mut hasher = DefaultHasher::new();
440 base.hash(&mut hasher);
441 used.len().hash(&mut hasher);
442 let fallback = format!("{base}_{:08x}", hasher.finish() as u32);
443 used.insert(fallback.clone());
444 return fallback;
445 }
446 }
447 }
448
449 pub fn sanitize_js_identifier(name: &str) -> String {
459 let mut result = String::new();
460 let mut chars = name.chars().peekable();
461
462 if let Some(&first) = chars.peek() {
464 if first.is_ascii_digit() {
465 result.push('_');
466 }
467 }
468
469 for ch in chars {
470 if ch.is_ascii_alphanumeric() || ch == '_' {
471 result.push(ch);
472 } else {
473 if !result.ends_with('_') {
476 result.push('_');
477 }
478 }
479 }
480
481 result = result.trim_end_matches('_').to_string();
483
484 if result.is_empty() {
486 result = "operation".to_string();
487 }
488
489 result
490 }
491
492 fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
494 let stages = self
495 .config
496 .scenario
497 .generate_stages(self.config.duration_secs, self.config.max_vus);
498
499 let base_path = self.config.base_path.as_deref().unwrap_or("");
501
502 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
504 let mut used_js_names: HashSet<String> = HashSet::new();
507 let mut used_metric_names: HashSet<String> = HashSet::new();
508
509 let mut operations = Vec::with_capacity(self.templates.len());
510 for (idx, template) in self.templates.iter().enumerate() {
511 let display_name = template.operation.display_name();
512 let sanitized_name = Self::uniquify_name(
513 Self::sanitize_js_identifier(&display_name),
514 &mut used_js_names,
515 );
516 let metric_name = Self::uniquify_name(
524 Self::sanitize_k6_metric_name(&display_name),
525 &mut used_metric_names,
526 );
527 let k6_method = match template.operation.method.to_lowercase().as_str() {
529 "delete" => "del".to_string(),
530 m => m.to_string(),
531 };
532 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
535
536 let raw_path = template.generate_path();
539 let full_path = join_base_path(base_path, &raw_path);
540 let processed_path = DynamicParamProcessor::process_path(&full_path);
541 all_placeholders.extend(processed_path.placeholders.clone());
542
543 let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
545 let processed_body = DynamicParamProcessor::process_json_body(body);
546 all_placeholders.extend(processed_body.placeholders.clone());
547 (Some(processed_body.value), processed_body.is_dynamic)
548 } else {
549 (None, false)
550 };
551
552 let path_value = if processed_path.is_dynamic {
562 processed_path.value
563 } else {
564 serde_json::to_string(&full_path).unwrap_or_else(|_| "\"/\"".to_string())
565 };
566
567 operations.push(K6OperationData {
568 index: idx,
569 name: sanitized_name,
570 metric_name,
571 display_name,
572 method: k6_method,
573 path: Value::String(path_value),
574 path_is_dynamic: processed_path.is_dynamic,
575 headers: Value::String(self.build_headers_json(template)),
576 body: body_value.map(Value::String),
577 body_is_dynamic,
578 has_body: template.body.is_some(),
579 is_get_or_head,
580 });
581 }
582
583 let required_imports: Vec<String> =
585 DynamicParamProcessor::get_required_imports(&all_placeholders)
586 .into_iter()
587 .map(String::from)
588 .collect();
589 let required_globals: Vec<String> =
590 DynamicParamProcessor::get_required_globals(&all_placeholders)
591 .into_iter()
592 .map(String::from)
593 .collect();
594 let has_dynamic_values = !all_placeholders.is_empty();
595
596 Ok(K6ScriptTemplateData {
597 base_url: self.config.target_url.clone(),
598 stages: stages
599 .iter()
600 .map(|s| K6StageData {
601 duration: s.duration.clone(),
602 target: s.target,
603 })
604 .collect(),
605 operations,
606 threshold_percentile: self.config.threshold_percentile.clone(),
607 threshold_ms: self.config.threshold_ms,
608 max_error_rate: self.config.max_error_rate,
609 abort_on_error: self.abort_on_error,
610 abort_on_error_rate: self.abort_on_error_rate,
611 scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
612 skip_tls_verify: self.config.skip_tls_verify,
613 has_dynamic_values,
614 dynamic_imports: required_imports,
615 dynamic_globals: required_globals,
616 security_testing_enabled: self.config.security_testing_enabled,
617 has_custom_headers: !self.config.custom_headers.is_empty(),
618 chunked_request_bodies: self.config.chunked_request_bodies,
619 target_rps: self.config.target_rps,
620 no_keep_alive: self.config.no_keep_alive,
621 duration_secs: self.config.duration_secs,
622 max_vus: self.config.max_vus,
623 start_vus: match self.config.scenario {
627 LoadScenario::Constant => self.config.max_vus,
628 _ => 0,
629 },
630 geo_source_ips: self.config.geo_source_ips.clone(),
638 geo_source_headers: self.config.geo_source_headers.clone(),
639 has_geo_source: !self.config.geo_source_ips.is_empty()
640 && !self.config.geo_source_headers.is_empty(),
641 geo_source_ips_json: serde_json::to_string(&self.config.geo_source_ips)
642 .unwrap_or_else(|_| "[]".to_string()),
643 geo_source_headers_json: serde_json::to_string(&self.config.geo_source_headers)
644 .unwrap_or_else(|_| "[]".to_string()),
645 force_http1: self.should_force_http1(),
646 per_op_metrics: self.per_op_metrics,
647 })
648 }
649
650 fn build_headers_json(&self, template: &RequestTemplate) -> String {
652 let mut headers = template.get_headers();
653
654 if let Some(auth) = &self.config.auth_header {
656 headers.insert("Authorization".to_string(), auth.clone());
657 }
658
659 for (key, value) in &self.config.custom_headers {
661 headers.insert(key.clone(), value.clone());
662 }
663
664 if self.config.chunked_request_bodies && template.body.is_some() {
669 headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
670 }
671
672 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
674 }
675
676 pub fn validate_script(script: &str) -> Vec<String> {
685 let mut errors = Vec::new();
686
687 if !script.contains("import http from 'k6/http'") {
689 errors.push("Missing required import: 'k6/http'".to_string());
690 }
691 if !script.contains("import { check") && !script.contains("import {check") {
692 errors.push("Missing required import: 'check' from 'k6'".to_string());
693 }
694 if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
695 errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
696 }
697
698 let lines: Vec<&str> = script.lines().collect();
702 let mut seen_metric_consts: HashSet<String> = HashSet::new();
703 for (line_num, line) in lines.iter().enumerate() {
704 let trimmed = line.trim();
705
706 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
708 if let Some(name) = trimmed
712 .strip_prefix("const ")
713 .and_then(|rest| rest.split('=').next())
714 .map(str::trim)
715 .filter(|n| {
716 !n.is_empty() && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
717 })
718 {
719 if !seen_metric_consts.insert(name.to_string()) {
720 errors.push(format!(
721 "Line {}: duplicate const '{name}'. k6 exits 107 (ScriptException) when two traffic cases sanitize to the same identifier.",
722 line_num + 1
723 ));
724 }
725 }
726 if let Some(start) = trimmed.find('\'') {
729 if let Some(end) = trimmed[start + 1..].find('\'') {
730 let metric_name = &trimmed[start + 1..start + 1 + end];
731 if !Self::is_valid_k6_metric_name(metric_name) {
732 errors.push(format!(
733 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
734 line_num + 1,
735 metric_name
736 ));
737 }
738 }
739 } else if let Some(start) = trimmed.find('"') {
740 if let Some(end) = trimmed[start + 1..].find('"') {
741 let metric_name = &trimmed[start + 1..start + 1 + end];
742 if !Self::is_valid_k6_metric_name(metric_name) {
743 errors.push(format!(
744 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
745 line_num + 1,
746 metric_name
747 ));
748 }
749 }
750 }
751 }
752
753 if !trimmed.starts_with("//") {
760 if let Some(col) = Self::invalid_js_hex_escape_column(trimmed) {
761 errors.push(format!(
762 "Line {}:{}: invalid JS hex escape \\x (k6 requires two hex digits). Static paths must be JSON-encoded, not dumped into a template literal.",
763 line_num + 1,
764 col + 1
765 ));
766 }
767 }
768
769 if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
771 if let Some(equals_pos) = trimmed.find('=') {
772 let var_decl = &trimmed[..equals_pos];
773 if var_decl.contains('.')
776 && !var_decl.contains("'")
777 && !var_decl.contains("\"")
778 && !var_decl.trim().starts_with("//")
779 {
780 errors.push(format!(
781 "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
782 line_num + 1,
783 var_decl.trim()
784 ));
785 }
786 }
787 }
788 }
789
790 errors
791 }
792
793 fn invalid_js_hex_escape_column(line: &str) -> Option<usize> {
800 let bytes = line.as_bytes();
801 let mut i = 0;
802 while i + 1 < bytes.len() {
803 if bytes[i] == b'\\' && bytes[i + 1] == b'x' {
804 let mut preceding = 0usize;
805 let mut j = i;
806 while j > 0 && bytes[j - 1] == b'\\' {
807 preceding += 1;
808 j -= 1;
809 }
810 if preceding.is_multiple_of(2) {
813 let hex_ok = i + 3 < bytes.len()
814 && bytes[i + 2].is_ascii_hexdigit()
815 && bytes[i + 3].is_ascii_hexdigit();
816 if !hex_ok {
817 return Some(i);
818 }
819 }
820 }
821 i += 1;
822 }
823 None
824 }
825
826 fn is_valid_k6_metric_name(name: &str) -> bool {
833 if name.is_empty() || name.len() > 128 {
834 return false;
835 }
836
837 let mut chars = name.chars();
838
839 if let Some(first) = chars.next() {
841 if !first.is_ascii_alphabetic() && first != '_' {
842 return false;
843 }
844 }
845
846 for ch in chars {
848 if !ch.is_ascii_alphanumeric() && ch != '_' {
849 return false;
850 }
851 }
852
853 true
854 }
855}
856
857fn join_base_path(base_path: &str, raw_path: &str) -> String {
863 let joined = join_base_path_raw(base_path, raw_path);
864 if joined.starts_with('/') {
868 joined
869 } else {
870 format!("/{joined}")
871 }
872}
873
874fn join_base_path_raw(base_path: &str, raw_path: &str) -> String {
875 match base_path {
876 "" | "/" => raw_path.to_string(),
877 bp => {
878 let bp = bp.trim_end_matches('/');
879 if raw_path.starts_with('/') {
880 format!("{}{}", bp, raw_path)
881 } else {
882 format!("{}/{}", bp, raw_path)
883 }
884 }
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 #[test]
893 fn root_base_path_does_not_double_slash() {
894 assert_eq!(
895 join_base_path(
896 "/",
897 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
898 ),
899 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
900 );
901 assert_eq!(join_base_path("", "/pets"), "/pets");
902 assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
903 assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
904 assert_eq!(join_base_path("@localhost:6565", "/pets"), "/@localhost:6565/pets");
905 assert_eq!(join_base_path("", "pets"), "/pets");
906 }
907
908 #[test]
909 fn test_k6_config_creation() {
910 let config = K6Config {
911 target_url: "https://api.example.com".to_string(),
912 base_path: None,
913 scenario: LoadScenario::RampUp,
914 duration_secs: 60,
915 max_vus: 10,
916 threshold_percentile: "p(95)".to_string(),
917 threshold_ms: 500,
918 max_error_rate: 0.05,
919 auth_header: None,
920 custom_headers: HashMap::new(),
921 skip_tls_verify: false,
922 security_testing_enabled: false,
923 chunked_request_bodies: false,
924 target_rps: None,
925 no_keep_alive: false,
926 geo_source_ips: Vec::new(),
927 geo_source_headers: Vec::new(),
928 };
929
930 assert_eq!(config.duration_secs, 60);
931 assert_eq!(config.max_vus, 10);
932 }
933
934 #[test]
935 fn test_script_generator_creation() {
936 let config = K6Config {
937 target_url: "https://api.example.com".to_string(),
938 base_path: None,
939 scenario: LoadScenario::Constant,
940 duration_secs: 30,
941 max_vus: 5,
942 threshold_percentile: "p(95)".to_string(),
943 threshold_ms: 500,
944 max_error_rate: 0.05,
945 auth_header: None,
946 custom_headers: HashMap::new(),
947 skip_tls_verify: false,
948 security_testing_enabled: false,
949 chunked_request_bodies: false,
950 target_rps: None,
951 no_keep_alive: false,
952 geo_source_ips: Vec::new(),
953 geo_source_headers: Vec::new(),
954 };
955
956 let templates = vec![];
957 let generator = K6ScriptGenerator::new(config, templates);
958
959 assert_eq!(generator.templates.len(), 0);
960 }
961
962 #[test]
963 fn colliding_operation_titles_get_unique_const_names() {
964 use crate::spec_parser::ApiOperation;
968 use openapiv3::Operation;
969
970 fn tmpl(id: &str, path: &str) -> RequestTemplate {
971 RequestTemplate {
972 operation: ApiOperation {
973 method: "get".to_string(),
974 path: path.to_string(),
975 operation: Operation::default(),
976 operation_id: Some(id.to_string()),
977 },
978 path_params: HashMap::new(),
979 query_params: HashMap::new(),
980 headers: HashMap::new(),
981 body: None,
982 }
983 }
984
985 let config = K6Config {
986 target_url: "https://example.test".to_string(),
987 base_path: None,
988 scenario: LoadScenario::Constant,
989 duration_secs: 5,
990 max_vus: 1,
991 threshold_percentile: "p(95)".to_string(),
992 threshold_ms: 500,
993 max_error_rate: 0.05,
994 auth_header: None,
995 custom_headers: HashMap::new(),
996 skip_tls_verify: false,
997 security_testing_enabled: false,
998 chunked_request_bodies: false,
999 target_rps: None,
1000 no_keep_alive: false,
1001 geo_source_ips: Vec::new(),
1002 geo_source_headers: Vec::new(),
1003 };
1004 let generator = K6ScriptGenerator::new(
1005 config,
1006 vec![
1007 tmpl("normal request allowed", "/a"),
1008 tmpl("normal request allowed", "/b"),
1009 ],
1010 );
1011 let script = generator.generate().expect("script generates");
1012 let latency = script
1013 .lines()
1014 .filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
1015 .collect::<Vec<_>>();
1016 assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
1017 assert!(
1018 script.contains("const normal_request_allowed_latency = new Trend"),
1019 "first collision keeps the base name"
1020 );
1021 assert!(
1022 script.contains("const normal_request_allowed_2_latency = new Trend")
1023 || script.contains("const normal_request_allowed_latency_2 = new Trend"),
1024 "second collision must be renamed, script snippet:\n{}",
1025 latency.join("\n")
1026 );
1027 let errors = K6ScriptGenerator::validate_script(&script);
1028 assert!(errors.is_empty(), "validate_script: {errors:#?}");
1029 }
1030
1031 #[test]
1032 fn werkzeug_unc_backslash_x_is_json_encoded_not_template_literal() {
1033 use crate::spec_parser::ApiOperation;
1038 use openapiv3::Operation;
1039
1040 let path = "/static/\\\\attacker.com\\share\\x";
1041 let template = RequestTemplate {
1042 operation: ApiOperation {
1043 method: "get".to_string(),
1044 path: path.to_string(),
1045 operation: Operation::default(),
1046 operation_id: Some("literal UNC double-backslash path blocked".to_string()),
1047 },
1048 path_params: HashMap::new(),
1049 query_params: HashMap::new(),
1050 headers: HashMap::new(),
1051 body: None,
1052 };
1053 let config = K6Config {
1054 target_url: "https://example.test".to_string(),
1055 base_path: None,
1056 scenario: LoadScenario::Constant,
1057 duration_secs: 5,
1058 max_vus: 1,
1059 threshold_percentile: "p(95)".to_string(),
1060 threshold_ms: 500,
1061 max_error_rate: 0.05,
1062 auth_header: None,
1063 custom_headers: HashMap::new(),
1064 skip_tls_verify: false,
1065 security_testing_enabled: false,
1066 chunked_request_bodies: false,
1067 target_rps: None,
1068 no_keep_alive: false,
1069 geo_source_ips: Vec::new(),
1070 geo_source_headers: Vec::new(),
1071 };
1072 let script = K6ScriptGenerator::new(config, vec![template])
1073 .generate()
1074 .expect("script generates");
1075 let encoded = serde_json::to_string(path).expect("path JSON");
1076 assert!(
1077 script.contains(&format!("BASE_URL + {encoded}")),
1078 "expected BASE_URL + {encoded} in script:\n{script}"
1079 );
1080 assert!(
1081 !script.contains("${BASE_URL}/static/"),
1082 "must not dump the raw path into a template literal:\n{script}"
1083 );
1084 let errors = K6ScriptGenerator::validate_script(&script);
1085 assert!(errors.is_empty(), "validate_script: {errors:#?}\n{script}");
1086 }
1087
1088 #[test]
1089 fn validate_script_flags_bare_hex_escape_in_template_literal() {
1090 let bad = r#"
1093import http from 'k6/http';
1094import { check, sleep } from 'k6';
1095import { Rate, Trend } from 'k6/metrics';
1096const t_latency = new Trend('t_latency');
1097export default function() {
1098 const res = http.get(`${BASE_URL}/static/\\attacker.com\share\x`);
1099}
1100"#;
1101 let errors = K6ScriptGenerator::validate_script(bad);
1102 assert!(
1103 errors.iter().any(|e| e.contains("invalid JS hex escape")),
1104 "expected hex-escape error, got {errors:#?}"
1105 );
1106 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
1107 r#"http.get(`${BASE_URL}/static/\\attacker.com\share\x`)"#
1108 )
1109 .is_some());
1110 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
1111 r#"BASE_URL + "/static/\\\\attacker.com\\share\\x""#
1112 )
1113 .is_none());
1114 }
1115
1116 #[test]
1117 fn test_sanitize_js_identifier() {
1118 assert_eq!(
1120 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
1121 "billing_subscriptions_v1"
1122 );
1123
1124 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
1126
1127 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
1129
1130 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
1132
1133 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
1135
1136 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
1138
1139 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
1141
1142 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
1144 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
1145 assert_eq!(
1146 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
1147 "plans_update_pricing_schemes"
1148 );
1149 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
1150 }
1151
1152 #[test]
1153 fn test_sanitize_k6_metric_name_short_passthrough() {
1154 let short = "billing_subscriptions_list";
1156 let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
1157 assert_eq!(out, short);
1158 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
1159 }
1160
1161 #[test]
1162 fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
1163 let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
1167 charts.workbookChart.axes.categoryAxis.format.line.clear";
1168 let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
1169
1170 assert!(
1172 metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
1173 "metric base len {} exceeded cap {}",
1174 metric.len(),
1175 K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
1176 );
1177
1178 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1180 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
1181 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
1182 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
1184 }
1185
1186 #[test]
1187 fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
1188 let prefix = "a".repeat(150);
1191 let a = format!("{prefix}.foo");
1192 let b = format!("{prefix}.bar");
1193 let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
1194 let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
1195 assert_ne!(ma, mb, "distinct long names produced the same metric name");
1196 }
1197
1198 #[test]
1199 fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
1200 let long = format!("{}123end", "x".repeat(120));
1202 let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
1203 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1204 }
1205
1206 #[test]
1207 fn test_microsoft_graph_long_operation_id_passes_validation() {
1208 use crate::spec_parser::ApiOperation;
1211 use openapiv3::Operation;
1212
1213 let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
1214 workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
1215 line.clear";
1216
1217 let operation = ApiOperation {
1218 method: "post".to_string(),
1219 path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
1220 operation: Operation::default(),
1221 operation_id: Some(long_op_id.to_string()),
1222 };
1223 let template = RequestTemplate {
1224 operation,
1225 path_params: HashMap::new(),
1226 query_params: HashMap::new(),
1227 headers: HashMap::new(),
1228 body: None,
1229 };
1230 let config = K6Config {
1231 target_url: "https://api.example.com".to_string(),
1232 base_path: Some("/v1.0".to_string()),
1233 scenario: LoadScenario::Constant,
1234 duration_secs: 30,
1235 max_vus: 5,
1236 threshold_percentile: "p(95)".to_string(),
1237 threshold_ms: 500,
1238 max_error_rate: 0.05,
1239 auth_header: None,
1240 custom_headers: HashMap::new(),
1241 skip_tls_verify: false,
1242 security_testing_enabled: false,
1243 chunked_request_bodies: false,
1244 target_rps: None,
1245 no_keep_alive: false,
1246 geo_source_ips: Vec::new(),
1247 geo_source_headers: Vec::new(),
1248 };
1249 let generator = K6ScriptGenerator::new(config, vec![template]);
1250 let script = generator.generate().expect("script generates");
1251
1252 let errors = K6ScriptGenerator::validate_script(&script);
1253 assert!(
1254 errors.is_empty(),
1255 "validate_script returned errors for long operationId: {errors:#?}"
1256 );
1257 }
1258
1259 #[test]
1264 fn test_abort_valve_opt_out_and_rate() {
1265 fn base_config() -> K6Config {
1266 K6Config {
1267 target_url: "https://api.example.com".to_string(),
1268 base_path: None,
1269 scenario: LoadScenario::Constant,
1270 duration_secs: 30,
1271 max_vus: 5,
1272 threshold_percentile: "p(95)".to_string(),
1273 threshold_ms: 500,
1274 max_error_rate: 0.05,
1275 auth_header: None,
1276 custom_headers: HashMap::new(),
1277 skip_tls_verify: false,
1278 security_testing_enabled: false,
1279 chunked_request_bodies: false,
1280 target_rps: None,
1281 no_keep_alive: false,
1282 geo_source_ips: Vec::new(),
1283 geo_source_headers: Vec::new(),
1284 }
1285 }
1286
1287 let default_script = K6ScriptGenerator::new(base_config(), vec![])
1289 .generate()
1290 .expect("script generates");
1291 assert!(
1292 default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
1293 "default script must keep the 0.95 abort valve"
1294 );
1295
1296 let stress_script = K6ScriptGenerator::new(base_config(), vec![])
1299 .with_abort_valve(false, 0.95)
1300 .generate()
1301 .expect("script generates");
1302 assert!(
1305 !stress_script.contains("abortOnFail: true"),
1306 "--no-abort-on-error must drop the abortOnFail threshold"
1307 );
1308 assert!(stress_script.contains("rate<0.05"));
1310
1311 let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
1313 .with_abort_valve(true, 0.99)
1314 .generate()
1315 .expect("script generates");
1316 assert!(
1317 tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
1318 "--abort-on-error-rate must retune the valve threshold"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_script_generation_with_dots_in_name() {
1324 use crate::spec_parser::ApiOperation;
1325 use openapiv3::Operation;
1326
1327 let operation = ApiOperation {
1329 method: "get".to_string(),
1330 path: "/billing/subscriptions".to_string(),
1331 operation: Operation::default(),
1332 operation_id: Some("billing.subscriptions.v1".to_string()),
1333 };
1334
1335 let template = RequestTemplate {
1336 operation,
1337 path_params: HashMap::new(),
1338 query_params: HashMap::new(),
1339 headers: HashMap::new(),
1340 body: None,
1341 };
1342
1343 let config = K6Config {
1344 target_url: "https://api.example.com".to_string(),
1345 base_path: None,
1346 scenario: LoadScenario::Constant,
1347 duration_secs: 30,
1348 max_vus: 5,
1349 threshold_percentile: "p(95)".to_string(),
1350 threshold_ms: 500,
1351 max_error_rate: 0.05,
1352 auth_header: None,
1353 custom_headers: HashMap::new(),
1354 skip_tls_verify: false,
1355 security_testing_enabled: false,
1356 chunked_request_bodies: false,
1357 target_rps: None,
1358 no_keep_alive: false,
1359 geo_source_ips: Vec::new(),
1360 geo_source_headers: Vec::new(),
1361 };
1362
1363 let generator = K6ScriptGenerator::new(config, vec![template]);
1364 let script = generator.generate().expect("Should generate script");
1365
1366 assert!(
1368 script.contains("const billing_subscriptions_v1_latency"),
1369 "Script should contain sanitized variable name for latency"
1370 );
1371 assert!(
1372 script.contains("const billing_subscriptions_v1_errors"),
1373 "Script should contain sanitized variable name for errors"
1374 );
1375
1376 assert!(
1379 !script.contains("const billing.subscriptions"),
1380 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
1381 );
1382
1383 assert!(
1386 script.contains("'billing_subscriptions_v1_latency'"),
1387 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1388 );
1389 assert!(
1390 script.contains("'billing_subscriptions_v1_errors'"),
1391 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1392 );
1393
1394 assert!(
1396 script.contains("billing.subscriptions.v1"),
1397 "Script should contain original name in comments/strings for readability"
1398 );
1399
1400 assert!(
1402 script.contains("billing_subscriptions_v1_latency.add"),
1403 "Variable usage should use sanitized name"
1404 );
1405 assert!(
1406 script.contains("billing_subscriptions_v1_errors.add"),
1407 "Variable usage should use sanitized name"
1408 );
1409 }
1410
1411 #[test]
1418 fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
1419 use crate::spec_parser::ApiOperation;
1420 use openapiv3::Operation;
1421
1422 let operation = ApiOperation {
1423 method: "get".to_string(),
1424 path: "/users".to_string(),
1425 operation: Operation::default(),
1426 operation_id: Some("listUsers".to_string()),
1427 };
1428 let template = RequestTemplate {
1429 operation,
1430 path_params: HashMap::new(),
1431 query_params: HashMap::new(),
1432 headers: HashMap::new(),
1433 body: None,
1434 };
1435
1436 let config = K6Config {
1437 target_url: "https://api.example.com".to_string(),
1438 base_path: None,
1439 scenario: LoadScenario::RampUp,
1440 duration_secs: 600,
1441 max_vus: 100,
1442 threshold_percentile: "p(95)".to_string(),
1443 threshold_ms: 500,
1444 max_error_rate: 0.05,
1445 auth_header: None,
1446 custom_headers: HashMap::new(),
1447 skip_tls_verify: false,
1448 security_testing_enabled: false,
1449 chunked_request_bodies: false,
1450 target_rps: Some(100),
1451 no_keep_alive: false,
1452 geo_source_ips: Vec::new(),
1453 geo_source_headers: Vec::new(),
1454 };
1455
1456 let generator = K6ScriptGenerator::new(config, vec![template]);
1457 let script = generator.generate().expect("Should generate script");
1458
1459 assert!(
1460 script.contains("constant-arrival-rate"),
1461 "with --rps set, executor must switch to constant-arrival-rate"
1462 );
1463 assert!(
1464 script.contains("rate: 100,"),
1465 "constant-arrival-rate must use the configured --rps as `rate`"
1466 );
1467 assert!(
1468 script.contains("duration: '600s'"),
1469 "duration must come from --duration, not the ramp-down stage; got:\n{}",
1470 script
1471 );
1472 assert!(
1473 script.contains("preAllocatedVUs: 100,"),
1474 "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
1475 script
1476 );
1477 assert!(
1478 script.contains("maxVUs: 100,"),
1479 "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
1480 script
1481 );
1482 for (idx, line) in script.lines().enumerate() {
1486 let trimmed = line.trim_start();
1487 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1488 continue;
1489 }
1490 assert!(
1491 !trimmed.starts_with("preAllocatedVUs: 0"),
1492 "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
1493 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
1494 idx + 1,
1495 line,
1496 );
1497 }
1498 }
1499
1500 #[test]
1503 fn test_cps_sets_no_connection_reuse() {
1504 use crate::spec_parser::ApiOperation;
1505 use openapiv3::Operation;
1506
1507 let operation = ApiOperation {
1508 method: "get".to_string(),
1509 path: "/u".to_string(),
1510 operation: Operation::default(),
1511 operation_id: Some("u".to_string()),
1512 };
1513 let template = RequestTemplate {
1514 operation,
1515 path_params: HashMap::new(),
1516 query_params: HashMap::new(),
1517 headers: HashMap::new(),
1518 body: None,
1519 };
1520 let config = K6Config {
1521 target_url: "https://api.example.com".to_string(),
1522 base_path: None,
1523 scenario: LoadScenario::Constant,
1524 duration_secs: 30,
1525 max_vus: 5,
1526 threshold_percentile: "p(95)".to_string(),
1527 threshold_ms: 500,
1528 max_error_rate: 0.05,
1529 auth_header: None,
1530 custom_headers: HashMap::new(),
1531 skip_tls_verify: false,
1532 security_testing_enabled: false,
1533 chunked_request_bodies: false,
1534 target_rps: None,
1535 no_keep_alive: true,
1536 geo_source_ips: Vec::new(),
1537 geo_source_headers: Vec::new(),
1538 };
1539 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1540 assert!(
1541 script.contains("noConnectionReuse: true"),
1542 "--cps must set noConnectionReuse: true on the k6 options block"
1543 );
1544 assert!(
1545 script.contains("Total Connections:"),
1546 "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1547 );
1548 assert!(
1549 script.contains("Connection Rate:"),
1550 "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1551 );
1552 }
1553
1554 #[test]
1560 fn test_constant_scenario_starts_at_target_vus() {
1561 use crate::spec_parser::ApiOperation;
1562 use openapiv3::Operation;
1563
1564 let operation = ApiOperation {
1565 method: "get".to_string(),
1566 path: "/u".to_string(),
1567 operation: Operation::default(),
1568 operation_id: Some("u".to_string()),
1569 };
1570 let template = RequestTemplate {
1571 operation,
1572 path_params: HashMap::new(),
1573 query_params: HashMap::new(),
1574 headers: HashMap::new(),
1575 body: None,
1576 };
1577 let config = K6Config {
1578 target_url: "https://api.example.com".to_string(),
1579 base_path: None,
1580 scenario: LoadScenario::Constant,
1581 duration_secs: 600,
1582 max_vus: 5,
1583 threshold_percentile: "p(95)".to_string(),
1584 threshold_ms: 500,
1585 max_error_rate: 0.05,
1586 auth_header: None,
1587 custom_headers: HashMap::new(),
1588 skip_tls_verify: false,
1589 security_testing_enabled: false,
1590 chunked_request_bodies: false,
1591 target_rps: None,
1592 no_keep_alive: false,
1593 geo_source_ips: Vec::new(),
1594 geo_source_headers: Vec::new(),
1595 };
1596 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1597 assert!(
1598 script.contains("startVUs: 5,"),
1599 "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1600 script
1601 );
1602 let ramp_config = K6Config {
1604 target_url: "https://api.example.com".to_string(),
1605 base_path: None,
1606 scenario: LoadScenario::RampUp,
1607 duration_secs: 600,
1608 max_vus: 5,
1609 threshold_percentile: "p(95)".to_string(),
1610 threshold_ms: 500,
1611 max_error_rate: 0.05,
1612 auth_header: None,
1613 custom_headers: HashMap::new(),
1614 skip_tls_verify: false,
1615 security_testing_enabled: false,
1616 chunked_request_bodies: false,
1617 target_rps: None,
1618 no_keep_alive: false,
1619 geo_source_ips: Vec::new(),
1620 geo_source_headers: Vec::new(),
1621 };
1622 let ramp_template = RequestTemplate {
1623 operation: ApiOperation {
1624 method: "get".to_string(),
1625 path: "/u".to_string(),
1626 operation: Operation::default(),
1627 operation_id: Some("u".to_string()),
1628 },
1629 path_params: HashMap::new(),
1630 query_params: HashMap::new(),
1631 headers: HashMap::new(),
1632 body: None,
1633 };
1634 let ramp_script =
1635 K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1636 assert!(
1637 ramp_script.contains("startVUs: 0,"),
1638 "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1639 ramp_script
1640 );
1641 }
1642
1643 #[test]
1652 fn test_connections_opened_counter_present() {
1653 use crate::spec_parser::ApiOperation;
1654 use openapiv3::Operation;
1655
1656 let operation = ApiOperation {
1657 method: "get".to_string(),
1658 path: "/u".to_string(),
1659 operation: Operation::default(),
1660 operation_id: Some("u".to_string()),
1661 };
1662 let template = RequestTemplate {
1663 operation,
1664 path_params: HashMap::new(),
1665 query_params: HashMap::new(),
1666 headers: HashMap::new(),
1667 body: None,
1668 };
1669 let config = K6Config {
1670 target_url: "https://api.example.com".to_string(),
1671 base_path: None,
1672 scenario: LoadScenario::Constant,
1673 duration_secs: 30,
1674 max_vus: 5,
1675 threshold_percentile: "p(95)".to_string(),
1676 threshold_ms: 500,
1677 max_error_rate: 0.05,
1678 auth_header: None,
1679 custom_headers: HashMap::new(),
1680 skip_tls_verify: false,
1681 security_testing_enabled: false,
1682 chunked_request_bodies: false,
1683 target_rps: Some(50),
1684 no_keep_alive: false,
1685 geo_source_ips: Vec::new(),
1686 geo_source_headers: Vec::new(),
1687 };
1688 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1689 assert!(
1690 script.contains("new Counter('mockforge_connections_opened')"),
1691 "template must declare the mockforge_connections_opened Counter"
1692 );
1693 assert!(
1694 script.contains("mockforge_connections_opened.add(1)"),
1695 "template must increment mockforge_connections_opened on new TCP connect"
1696 );
1697 assert!(
1698 script.contains("res.timings.connecting > 0"),
1699 "template must gate the connection-opened increment on \
1700 res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1701 );
1702 }
1703
1704 #[test]
1705 fn test_validate_script_valid() {
1706 let valid_script = r#"
1707import http from 'k6/http';
1708import { check, sleep } from 'k6';
1709import { Rate, Trend } from 'k6/metrics';
1710
1711const test_latency = new Trend('test_latency');
1712const test_errors = new Rate('test_errors');
1713
1714export default function() {
1715 const res = http.get('https://example.com');
1716 test_latency.add(res.timings.duration);
1717 test_errors.add(res.status !== 200);
1718}
1719"#;
1720
1721 let errors = K6ScriptGenerator::validate_script(valid_script);
1722 assert!(errors.is_empty(), "Valid script should have no validation errors");
1723 }
1724
1725 #[test]
1726 fn test_validate_script_invalid_metric_name() {
1727 let invalid_script = r#"
1728import http from 'k6/http';
1729import { check, sleep } from 'k6';
1730import { Rate, Trend } from 'k6/metrics';
1731
1732const test_latency = new Trend('test.latency');
1733const test_errors = new Rate('test_errors');
1734
1735export default function() {
1736 const res = http.get('https://example.com');
1737 test_latency.add(res.timings.duration);
1738}
1739"#;
1740
1741 let errors = K6ScriptGenerator::validate_script(invalid_script);
1742 assert!(
1743 !errors.is_empty(),
1744 "Script with invalid metric name should have validation errors"
1745 );
1746 assert!(
1747 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1748 "Should detect invalid metric name with dot"
1749 );
1750 }
1751
1752 #[test]
1753 fn test_validate_script_missing_imports() {
1754 let invalid_script = r#"
1755const test_latency = new Trend('test_latency');
1756export default function() {}
1757"#;
1758
1759 let errors = K6ScriptGenerator::validate_script(invalid_script);
1760 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1761 }
1762
1763 #[test]
1764 fn test_validate_script_metric_name_validation() {
1765 let valid_script = r#"
1768import http from 'k6/http';
1769import { check, sleep } from 'k6';
1770import { Rate, Trend } from 'k6/metrics';
1771const test_latency = new Trend('test_latency');
1772const test_errors = new Rate('test_errors');
1773export default function() {}
1774"#;
1775 let errors = K6ScriptGenerator::validate_script(valid_script);
1776 assert!(errors.is_empty(), "Valid metric names should pass validation");
1777
1778 let invalid_cases = vec![
1780 ("test.latency", "dot in metric name"),
1781 ("123test", "starts with number"),
1782 ("test-latency", "hyphen in metric name"),
1783 ("test@latency", "special character"),
1784 ];
1785
1786 for (invalid_name, description) in invalid_cases {
1787 let script = format!(
1788 r#"
1789import http from 'k6/http';
1790import {{ check, sleep }} from 'k6';
1791import {{ Rate, Trend }} from 'k6/metrics';
1792const test_latency = new Trend('{}');
1793export default function() {{}}
1794"#,
1795 invalid_name
1796 );
1797 let errors = K6ScriptGenerator::validate_script(&script);
1798 assert!(
1799 !errors.is_empty(),
1800 "Metric name '{}' ({}) should fail validation",
1801 invalid_name,
1802 description
1803 );
1804 }
1805 }
1806
1807 #[test]
1808 fn test_skip_tls_verify_with_body() {
1809 use crate::spec_parser::ApiOperation;
1810 use openapiv3::Operation;
1811 use serde_json::json;
1812
1813 let operation = ApiOperation {
1815 method: "post".to_string(),
1816 path: "/api/users".to_string(),
1817 operation: Operation::default(),
1818 operation_id: Some("createUser".to_string()),
1819 };
1820
1821 let template = RequestTemplate {
1822 operation,
1823 path_params: HashMap::new(),
1824 query_params: HashMap::new(),
1825 headers: HashMap::new(),
1826 body: Some(json!({"name": "test"})),
1827 };
1828
1829 let config = K6Config {
1830 target_url: "https://api.example.com".to_string(),
1831 base_path: None,
1832 scenario: LoadScenario::Constant,
1833 duration_secs: 30,
1834 max_vus: 5,
1835 threshold_percentile: "p(95)".to_string(),
1836 threshold_ms: 500,
1837 max_error_rate: 0.05,
1838 auth_header: None,
1839 custom_headers: HashMap::new(),
1840 skip_tls_verify: true,
1841 security_testing_enabled: false,
1842 chunked_request_bodies: false,
1843 target_rps: None,
1844 no_keep_alive: false,
1845 geo_source_ips: Vec::new(),
1846 geo_source_headers: Vec::new(),
1847 };
1848
1849 let generator = K6ScriptGenerator::new(config, vec![template]);
1850 let script = generator.generate().expect("Should generate script");
1851
1852 assert!(
1854 script.contains("insecureSkipTLSVerify: true"),
1855 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_skip_tls_verify_without_body() {
1861 use crate::spec_parser::ApiOperation;
1862 use openapiv3::Operation;
1863
1864 let operation = ApiOperation {
1866 method: "get".to_string(),
1867 path: "/api/users".to_string(),
1868 operation: Operation::default(),
1869 operation_id: Some("getUsers".to_string()),
1870 };
1871
1872 let template = RequestTemplate {
1873 operation,
1874 path_params: HashMap::new(),
1875 query_params: HashMap::new(),
1876 headers: HashMap::new(),
1877 body: None,
1878 };
1879
1880 let config = K6Config {
1881 target_url: "https://api.example.com".to_string(),
1882 base_path: None,
1883 scenario: LoadScenario::Constant,
1884 duration_secs: 30,
1885 max_vus: 5,
1886 threshold_percentile: "p(95)".to_string(),
1887 threshold_ms: 500,
1888 max_error_rate: 0.05,
1889 auth_header: None,
1890 custom_headers: HashMap::new(),
1891 skip_tls_verify: true,
1892 security_testing_enabled: false,
1893 chunked_request_bodies: false,
1894 target_rps: None,
1895 no_keep_alive: false,
1896 geo_source_ips: Vec::new(),
1897 geo_source_headers: Vec::new(),
1898 };
1899
1900 let generator = K6ScriptGenerator::new(config, vec![template]);
1901 let script = generator.generate().expect("Should generate script");
1902
1903 assert!(
1905 script.contains("insecureSkipTLSVerify: true"),
1906 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1907 );
1908 }
1909
1910 #[test]
1911 fn test_no_skip_tls_verify() {
1912 use crate::spec_parser::ApiOperation;
1913 use openapiv3::Operation;
1914
1915 let operation = ApiOperation {
1917 method: "get".to_string(),
1918 path: "/api/users".to_string(),
1919 operation: Operation::default(),
1920 operation_id: Some("getUsers".to_string()),
1921 };
1922
1923 let template = RequestTemplate {
1924 operation,
1925 path_params: HashMap::new(),
1926 query_params: HashMap::new(),
1927 headers: HashMap::new(),
1928 body: None,
1929 };
1930
1931 let config = K6Config {
1932 target_url: "https://api.example.com".to_string(),
1933 base_path: None,
1934 scenario: LoadScenario::Constant,
1935 duration_secs: 30,
1936 max_vus: 5,
1937 threshold_percentile: "p(95)".to_string(),
1938 threshold_ms: 500,
1939 max_error_rate: 0.05,
1940 auth_header: None,
1941 custom_headers: HashMap::new(),
1942 skip_tls_verify: false,
1943 security_testing_enabled: false,
1944 chunked_request_bodies: false,
1945 target_rps: None,
1946 no_keep_alive: false,
1947 geo_source_ips: Vec::new(),
1948 geo_source_headers: Vec::new(),
1949 };
1950
1951 let generator = K6ScriptGenerator::new(config, vec![template]);
1952 let script = generator.generate().expect("Should generate script");
1953
1954 assert!(
1956 !script.contains("insecureSkipTLSVerify"),
1957 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_skip_tls_verify_multiple_operations() {
1963 use crate::spec_parser::ApiOperation;
1964 use openapiv3::Operation;
1965 use serde_json::json;
1966
1967 let operation1 = ApiOperation {
1969 method: "get".to_string(),
1970 path: "/api/users".to_string(),
1971 operation: Operation::default(),
1972 operation_id: Some("getUsers".to_string()),
1973 };
1974
1975 let operation2 = ApiOperation {
1976 method: "post".to_string(),
1977 path: "/api/users".to_string(),
1978 operation: Operation::default(),
1979 operation_id: Some("createUser".to_string()),
1980 };
1981
1982 let template1 = RequestTemplate {
1983 operation: operation1,
1984 path_params: HashMap::new(),
1985 query_params: HashMap::new(),
1986 headers: HashMap::new(),
1987 body: None,
1988 };
1989
1990 let template2 = RequestTemplate {
1991 operation: operation2,
1992 path_params: HashMap::new(),
1993 query_params: HashMap::new(),
1994 headers: HashMap::new(),
1995 body: Some(json!({"name": "test"})),
1996 };
1997
1998 let config = K6Config {
1999 target_url: "https://api.example.com".to_string(),
2000 base_path: None,
2001 scenario: LoadScenario::Constant,
2002 duration_secs: 30,
2003 max_vus: 5,
2004 threshold_percentile: "p(95)".to_string(),
2005 threshold_ms: 500,
2006 max_error_rate: 0.05,
2007 auth_header: None,
2008 custom_headers: HashMap::new(),
2009 skip_tls_verify: true,
2010 security_testing_enabled: false,
2011 chunked_request_bodies: false,
2012 target_rps: None,
2013 no_keep_alive: false,
2014 geo_source_ips: Vec::new(),
2015 geo_source_headers: Vec::new(),
2016 };
2017
2018 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
2019 let script = generator.generate().expect("Should generate script");
2020
2021 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
2024 assert_eq!(
2025 skip_count, 1,
2026 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
2027 );
2028
2029 let options_start = script.find("export const options = {").expect("Should have options");
2031 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
2032 let options_prefix = &script[options_start..scenarios_start];
2033 assert!(
2034 options_prefix.contains("insecureSkipTLSVerify: true"),
2035 "insecureSkipTLSVerify should be in global options block"
2036 );
2037 }
2038
2039 #[test]
2040 fn test_dynamic_params_in_body() {
2041 use crate::spec_parser::ApiOperation;
2042 use openapiv3::Operation;
2043 use serde_json::json;
2044
2045 let operation = ApiOperation {
2047 method: "post".to_string(),
2048 path: "/api/resources".to_string(),
2049 operation: Operation::default(),
2050 operation_id: Some("createResource".to_string()),
2051 };
2052
2053 let template = RequestTemplate {
2054 operation,
2055 path_params: HashMap::new(),
2056 query_params: HashMap::new(),
2057 headers: HashMap::new(),
2058 body: Some(json!({
2059 "name": "load-test-${__VU}",
2060 "iteration": "${__ITER}"
2061 })),
2062 };
2063
2064 let config = K6Config {
2065 target_url: "https://api.example.com".to_string(),
2066 base_path: None,
2067 scenario: LoadScenario::Constant,
2068 duration_secs: 30,
2069 max_vus: 5,
2070 threshold_percentile: "p(95)".to_string(),
2071 threshold_ms: 500,
2072 max_error_rate: 0.05,
2073 auth_header: None,
2074 custom_headers: HashMap::new(),
2075 skip_tls_verify: false,
2076 security_testing_enabled: false,
2077 chunked_request_bodies: false,
2078 target_rps: None,
2079 no_keep_alive: false,
2080 geo_source_ips: Vec::new(),
2081 geo_source_headers: Vec::new(),
2082 };
2083
2084 let generator = K6ScriptGenerator::new(config, vec![template]);
2085 let script = generator.generate().expect("Should generate script");
2086
2087 assert!(
2089 script.contains("Dynamic body with runtime placeholders"),
2090 "Script should contain comment about dynamic body"
2091 );
2092
2093 assert!(
2095 script.contains("__VU"),
2096 "Script should contain __VU reference for dynamic VU-based values"
2097 );
2098
2099 assert!(
2101 script.contains("__ITER"),
2102 "Script should contain __ITER reference for dynamic iteration values"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_dynamic_params_with_uuid() {
2108 use crate::spec_parser::ApiOperation;
2109 use openapiv3::Operation;
2110 use serde_json::json;
2111
2112 let operation = ApiOperation {
2114 method: "post".to_string(),
2115 path: "/api/resources".to_string(),
2116 operation: Operation::default(),
2117 operation_id: Some("createResource".to_string()),
2118 };
2119
2120 let template = RequestTemplate {
2121 operation,
2122 path_params: HashMap::new(),
2123 query_params: HashMap::new(),
2124 headers: HashMap::new(),
2125 body: Some(json!({
2126 "id": "${__UUID}"
2127 })),
2128 };
2129
2130 let config = K6Config {
2131 target_url: "https://api.example.com".to_string(),
2132 base_path: None,
2133 scenario: LoadScenario::Constant,
2134 duration_secs: 30,
2135 max_vus: 5,
2136 threshold_percentile: "p(95)".to_string(),
2137 threshold_ms: 500,
2138 max_error_rate: 0.05,
2139 auth_header: None,
2140 custom_headers: HashMap::new(),
2141 skip_tls_verify: false,
2142 security_testing_enabled: false,
2143 chunked_request_bodies: false,
2144 target_rps: None,
2145 no_keep_alive: false,
2146 geo_source_ips: Vec::new(),
2147 geo_source_headers: Vec::new(),
2148 };
2149
2150 let generator = K6ScriptGenerator::new(config, vec![template]);
2151 let script = generator.generate().expect("Should generate script");
2152
2153 assert!(
2156 !script.contains("k6/experimental/webcrypto"),
2157 "Script should NOT include deprecated k6/experimental/webcrypto import"
2158 );
2159
2160 assert!(
2162 script.contains("crypto.randomUUID()"),
2163 "Script should contain crypto.randomUUID() for UUID placeholder"
2164 );
2165 }
2166
2167 #[test]
2168 fn test_dynamic_params_with_counter() {
2169 use crate::spec_parser::ApiOperation;
2170 use openapiv3::Operation;
2171 use serde_json::json;
2172
2173 let operation = ApiOperation {
2175 method: "post".to_string(),
2176 path: "/api/resources".to_string(),
2177 operation: Operation::default(),
2178 operation_id: Some("createResource".to_string()),
2179 };
2180
2181 let template = RequestTemplate {
2182 operation,
2183 path_params: HashMap::new(),
2184 query_params: HashMap::new(),
2185 headers: HashMap::new(),
2186 body: Some(json!({
2187 "sequence": "${__COUNTER}"
2188 })),
2189 };
2190
2191 let config = K6Config {
2192 target_url: "https://api.example.com".to_string(),
2193 base_path: None,
2194 scenario: LoadScenario::Constant,
2195 duration_secs: 30,
2196 max_vus: 5,
2197 threshold_percentile: "p(95)".to_string(),
2198 threshold_ms: 500,
2199 max_error_rate: 0.05,
2200 auth_header: None,
2201 custom_headers: HashMap::new(),
2202 skip_tls_verify: false,
2203 security_testing_enabled: false,
2204 chunked_request_bodies: false,
2205 target_rps: None,
2206 no_keep_alive: false,
2207 geo_source_ips: Vec::new(),
2208 geo_source_headers: Vec::new(),
2209 };
2210
2211 let generator = K6ScriptGenerator::new(config, vec![template]);
2212 let script = generator.generate().expect("Should generate script");
2213
2214 assert!(
2216 script.contains("let globalCounter = 0"),
2217 "Script should include globalCounter initialization when COUNTER placeholder is used"
2218 );
2219
2220 assert!(
2222 script.contains("globalCounter++"),
2223 "Script should contain globalCounter++ for COUNTER placeholder"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_static_body_no_dynamic_marker() {
2229 use crate::spec_parser::ApiOperation;
2230 use openapiv3::Operation;
2231 use serde_json::json;
2232
2233 let operation = ApiOperation {
2235 method: "post".to_string(),
2236 path: "/api/resources".to_string(),
2237 operation: Operation::default(),
2238 operation_id: Some("createResource".to_string()),
2239 };
2240
2241 let template = RequestTemplate {
2242 operation,
2243 path_params: HashMap::new(),
2244 query_params: HashMap::new(),
2245 headers: HashMap::new(),
2246 body: Some(json!({
2247 "name": "static-value",
2248 "count": 42
2249 })),
2250 };
2251
2252 let config = K6Config {
2253 target_url: "https://api.example.com".to_string(),
2254 base_path: None,
2255 scenario: LoadScenario::Constant,
2256 duration_secs: 30,
2257 max_vus: 5,
2258 threshold_percentile: "p(95)".to_string(),
2259 threshold_ms: 500,
2260 max_error_rate: 0.05,
2261 auth_header: None,
2262 custom_headers: HashMap::new(),
2263 skip_tls_verify: false,
2264 security_testing_enabled: false,
2265 chunked_request_bodies: false,
2266 target_rps: None,
2267 no_keep_alive: false,
2268 geo_source_ips: Vec::new(),
2269 geo_source_headers: Vec::new(),
2270 };
2271
2272 let generator = K6ScriptGenerator::new(config, vec![template]);
2273 let script = generator.generate().expect("Should generate script");
2274
2275 assert!(
2277 !script.contains("Dynamic body with runtime placeholders"),
2278 "Script should NOT contain dynamic body comment for static body"
2279 );
2280
2281 assert!(
2283 !script.contains("webcrypto"),
2284 "Script should NOT include webcrypto import for static body"
2285 );
2286
2287 assert!(
2289 !script.contains("let globalCounter"),
2290 "Script should NOT include globalCounter for static body"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_security_testing_enabled_generates_calling_code() {
2296 use crate::spec_parser::ApiOperation;
2297 use openapiv3::Operation;
2298 use serde_json::json;
2299
2300 let operation = ApiOperation {
2301 method: "post".to_string(),
2302 path: "/api/users".to_string(),
2303 operation: Operation::default(),
2304 operation_id: Some("createUser".to_string()),
2305 };
2306
2307 let template = RequestTemplate {
2308 operation,
2309 path_params: HashMap::new(),
2310 query_params: HashMap::new(),
2311 headers: HashMap::new(),
2312 body: Some(json!({"name": "test"})),
2313 };
2314
2315 let config = K6Config {
2316 target_url: "https://api.example.com".to_string(),
2317 base_path: None,
2318 scenario: LoadScenario::Constant,
2319 duration_secs: 30,
2320 max_vus: 5,
2321 threshold_percentile: "p(95)".to_string(),
2322 threshold_ms: 500,
2323 max_error_rate: 0.05,
2324 auth_header: None,
2325 custom_headers: HashMap::new(),
2326 skip_tls_verify: false,
2327 security_testing_enabled: true,
2328 chunked_request_bodies: false,
2329 target_rps: None,
2330 no_keep_alive: false,
2331 geo_source_ips: Vec::new(),
2332 geo_source_headers: Vec::new(),
2333 };
2334
2335 let generator = K6ScriptGenerator::new(config, vec![template]);
2336 let script = generator.generate().expect("Should generate script");
2337
2338 assert!(
2340 script.contains("getNextSecurityPayload"),
2341 "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
2342 );
2343 assert!(
2344 script.contains("applySecurityPayload"),
2345 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
2346 );
2347 assert!(
2348 script.contains("secPayloadGroup"),
2349 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
2350 );
2351 assert!(
2352 script.contains("secBodyPayload"),
2353 "Script should contain secBodyPayload variable when security_testing_enabled is true"
2354 );
2355 assert!(
2357 script.contains("hasSecCookie"),
2358 "Script should track hasSecCookie for CookieJar conflict avoidance"
2359 );
2360 assert!(
2361 script.contains("secRequestOpts"),
2362 "Script should use secRequestOpts to conditionally skip CookieJar"
2363 );
2364 assert!(
2366 script.contains("const requestHeaders = { ..."),
2367 "Script should spread headers into mutable copy for security payload injection"
2368 );
2369 assert!(
2371 script.contains("secPayload.injectAsPath"),
2372 "Script should check injectAsPath for path-based URI injection"
2373 );
2374 assert!(
2376 script.contains("secBodyPayload.formBody"),
2377 "Script should check formBody for form-encoded body delivery"
2378 );
2379 assert!(
2380 script.contains("application/x-www-form-urlencoded"),
2381 "Script should set Content-Type for form-encoded body"
2382 );
2383 let op_comment_pos =
2385 script.find("// Operation 0:").expect("Should have Operation 0 comment");
2386 let sec_payload_pos = script
2387 .find("const secPayloadGroup = typeof getNextSecurityPayload")
2388 .expect("Should have secPayloadGroup assignment");
2389 assert!(
2390 sec_payload_pos > op_comment_pos,
2391 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
2392 );
2393 }
2394
2395 #[test]
2396 fn test_security_testing_disabled_no_calling_code() {
2397 use crate::spec_parser::ApiOperation;
2398 use openapiv3::Operation;
2399 use serde_json::json;
2400
2401 let operation = ApiOperation {
2402 method: "post".to_string(),
2403 path: "/api/users".to_string(),
2404 operation: Operation::default(),
2405 operation_id: Some("createUser".to_string()),
2406 };
2407
2408 let template = RequestTemplate {
2409 operation,
2410 path_params: HashMap::new(),
2411 query_params: HashMap::new(),
2412 headers: HashMap::new(),
2413 body: Some(json!({"name": "test"})),
2414 };
2415
2416 let config = K6Config {
2417 target_url: "https://api.example.com".to_string(),
2418 base_path: None,
2419 scenario: LoadScenario::Constant,
2420 duration_secs: 30,
2421 max_vus: 5,
2422 threshold_percentile: "p(95)".to_string(),
2423 threshold_ms: 500,
2424 max_error_rate: 0.05,
2425 auth_header: None,
2426 custom_headers: HashMap::new(),
2427 skip_tls_verify: false,
2428 security_testing_enabled: false,
2429 chunked_request_bodies: false,
2430 target_rps: None,
2431 no_keep_alive: false,
2432 geo_source_ips: Vec::new(),
2433 geo_source_headers: Vec::new(),
2434 };
2435
2436 let generator = K6ScriptGenerator::new(config, vec![template]);
2437 let script = generator.generate().expect("Should generate script");
2438
2439 assert!(
2441 !script.contains("getNextSecurityPayload"),
2442 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
2443 );
2444 assert!(
2445 !script.contains("applySecurityPayload"),
2446 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
2447 );
2448 assert!(
2449 !script.contains("secPayloadGroup"),
2450 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
2451 );
2452 assert!(
2453 !script.contains("secBodyPayload"),
2454 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
2455 );
2456 assert!(
2457 !script.contains("hasSecCookie"),
2458 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
2459 );
2460 assert!(
2461 !script.contains("secRequestOpts"),
2462 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
2463 );
2464 assert!(
2465 !script.contains("injectAsPath"),
2466 "Script should NOT contain injectAsPath when security_testing_enabled is false"
2467 );
2468 assert!(
2469 !script.contains("formBody"),
2470 "Script should NOT contain formBody when security_testing_enabled is false"
2471 );
2472 }
2473
2474 #[test]
2478 fn test_security_e2e_definitions_and_calls_both_present() {
2479 use crate::security_payloads::{
2480 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
2481 };
2482 use crate::spec_parser::ApiOperation;
2483 use openapiv3::Operation;
2484 use serde_json::json;
2485
2486 let operation = ApiOperation {
2488 method: "post".to_string(),
2489 path: "/api/users".to_string(),
2490 operation: Operation::default(),
2491 operation_id: Some("createUser".to_string()),
2492 };
2493
2494 let template = RequestTemplate {
2495 operation,
2496 path_params: HashMap::new(),
2497 query_params: HashMap::new(),
2498 headers: HashMap::new(),
2499 body: Some(json!({"name": "test"})),
2500 };
2501
2502 let config = K6Config {
2503 target_url: "https://api.example.com".to_string(),
2504 base_path: None,
2505 scenario: LoadScenario::Constant,
2506 duration_secs: 30,
2507 max_vus: 5,
2508 threshold_percentile: "p(95)".to_string(),
2509 threshold_ms: 500,
2510 max_error_rate: 0.05,
2511 auth_header: None,
2512 custom_headers: HashMap::new(),
2513 skip_tls_verify: false,
2514 security_testing_enabled: true,
2515 chunked_request_bodies: false,
2516 target_rps: None,
2517 no_keep_alive: false,
2518 geo_source_ips: Vec::new(),
2519 geo_source_headers: Vec::new(),
2520 };
2521
2522 let generator = K6ScriptGenerator::new(config, vec![template]);
2523 let mut script = generator.generate().expect("Should generate base script");
2524
2525 let security_config = SecurityTestConfig::default().enable();
2527 let payloads = SecurityPayloads::get_payloads(&security_config);
2528 assert!(!payloads.is_empty(), "Should have built-in payloads");
2529
2530 let mut additional_code = String::new();
2531 additional_code
2532 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
2533 additional_code.push('\n');
2534 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
2535 additional_code.push('\n');
2536
2537 if let Some(pos) = script.find("export const options") {
2539 script.insert_str(
2540 pos,
2541 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
2542 );
2543 }
2544
2545 assert!(
2548 script.contains("function getNextSecurityPayload()"),
2549 "Final script must contain getNextSecurityPayload function DEFINITION"
2550 );
2551 assert!(
2552 script.contains("function applySecurityPayload("),
2553 "Final script must contain applySecurityPayload function DEFINITION"
2554 );
2555 assert!(
2556 script.contains("securityPayloads"),
2557 "Final script must contain securityPayloads array"
2558 );
2559
2560 assert!(
2562 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2563 "Final script must contain secPayloadGroup assignment (template calling code)"
2564 );
2565 assert!(
2566 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2567 "Final script must contain applySecurityPayload CALL with secBodyPayload"
2568 );
2569 assert!(
2570 script.contains("const requestHeaders = { ..."),
2571 "Final script must spread headers for security payload header injection"
2572 );
2573 assert!(
2574 script.contains("for (const secPayload of secPayloadGroup)"),
2575 "Final script must loop over secPayloadGroup"
2576 );
2577 assert!(
2578 script.contains("secPayload.injectAsPath"),
2579 "Final script must check injectAsPath for path-based URI injection"
2580 );
2581 assert!(
2582 script.contains("secBodyPayload.formBody"),
2583 "Final script must check formBody for form-encoded body delivery"
2584 );
2585
2586 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2588 let call_pos =
2589 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2590 let options_pos = script.find("export const options").unwrap();
2591 let default_fn_pos = script.find("export default function").unwrap();
2592
2593 assert!(
2594 def_pos < options_pos,
2595 "Function definitions must appear before export const options"
2596 );
2597 assert!(
2598 call_pos > default_fn_pos,
2599 "Calling code must appear inside export default function"
2600 );
2601 }
2602
2603 #[test]
2605 fn test_security_uri_injection_for_get_requests() {
2606 use crate::spec_parser::ApiOperation;
2607 use openapiv3::Operation;
2608
2609 let operation = ApiOperation {
2610 method: "get".to_string(),
2611 path: "/api/users".to_string(),
2612 operation: Operation::default(),
2613 operation_id: Some("listUsers".to_string()),
2614 };
2615
2616 let template = RequestTemplate {
2617 operation,
2618 path_params: HashMap::new(),
2619 query_params: HashMap::new(),
2620 headers: HashMap::new(),
2621 body: None,
2622 };
2623
2624 let config = K6Config {
2625 target_url: "https://api.example.com".to_string(),
2626 base_path: None,
2627 scenario: LoadScenario::Constant,
2628 duration_secs: 30,
2629 max_vus: 5,
2630 threshold_percentile: "p(95)".to_string(),
2631 threshold_ms: 500,
2632 max_error_rate: 0.05,
2633 auth_header: None,
2634 custom_headers: HashMap::new(),
2635 skip_tls_verify: false,
2636 security_testing_enabled: true,
2637 chunked_request_bodies: false,
2638 target_rps: None,
2639 no_keep_alive: false,
2640 geo_source_ips: Vec::new(),
2641 geo_source_headers: Vec::new(),
2642 };
2643
2644 let generator = K6ScriptGenerator::new(config, vec![template]);
2645 let script = generator.generate().expect("Should generate script");
2646
2647 assert!(
2649 script.contains("requestUrl"),
2650 "Script should build requestUrl variable for URI payload injection"
2651 );
2652 assert!(
2653 script.contains("secPayload.location === 'uri'"),
2654 "Script should check for URI-location payloads"
2655 );
2656 assert!(
2658 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2659 "Script should URL-encode security payload in query string for valid HTTP"
2660 );
2661 assert!(
2663 script.contains("secPayload.injectAsPath"),
2664 "Script should check injectAsPath for path-based URI injection"
2665 );
2666 assert!(
2667 script.contains("encodeURI(secPayload.payload)"),
2668 "Script should use encodeURI for path-based injection"
2669 );
2670 assert!(
2672 script.contains("http.get(requestUrl,"),
2673 "GET request should use requestUrl (with URI injection) instead of inline URL"
2674 );
2675 }
2676
2677 #[test]
2679 fn test_security_uri_injection_for_post_requests() {
2680 use crate::spec_parser::ApiOperation;
2681 use openapiv3::Operation;
2682 use serde_json::json;
2683
2684 let operation = ApiOperation {
2685 method: "post".to_string(),
2686 path: "/api/users".to_string(),
2687 operation: Operation::default(),
2688 operation_id: Some("createUser".to_string()),
2689 };
2690
2691 let template = RequestTemplate {
2692 operation,
2693 path_params: HashMap::new(),
2694 query_params: HashMap::new(),
2695 headers: HashMap::new(),
2696 body: Some(json!({"name": "test"})),
2697 };
2698
2699 let config = K6Config {
2700 target_url: "https://api.example.com".to_string(),
2701 base_path: None,
2702 scenario: LoadScenario::Constant,
2703 duration_secs: 30,
2704 max_vus: 5,
2705 threshold_percentile: "p(95)".to_string(),
2706 threshold_ms: 500,
2707 max_error_rate: 0.05,
2708 auth_header: None,
2709 custom_headers: HashMap::new(),
2710 skip_tls_verify: false,
2711 security_testing_enabled: true,
2712 chunked_request_bodies: false,
2713 target_rps: None,
2714 no_keep_alive: false,
2715 geo_source_ips: Vec::new(),
2716 geo_source_headers: Vec::new(),
2717 };
2718
2719 let generator = K6ScriptGenerator::new(config, vec![template]);
2720 let script = generator.generate().expect("Should generate script");
2721
2722 assert!(
2724 script.contains("requestUrl"),
2725 "POST script should build requestUrl for URI payload injection"
2726 );
2727 assert!(
2728 script.contains("secPayload.location === 'uri'"),
2729 "POST script should check for URI-location payloads"
2730 );
2731 assert!(
2732 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2733 "POST script should apply security body payload to request body"
2734 );
2735 assert!(
2737 script.contains("http.post(requestUrl,"),
2738 "POST request should use requestUrl (with URI injection) instead of inline URL"
2739 );
2740 }
2741
2742 #[test]
2744 fn test_no_uri_injection_when_security_disabled() {
2745 use crate::spec_parser::ApiOperation;
2746 use openapiv3::Operation;
2747
2748 let operation = ApiOperation {
2749 method: "get".to_string(),
2750 path: "/api/users".to_string(),
2751 operation: Operation::default(),
2752 operation_id: Some("listUsers".to_string()),
2753 };
2754
2755 let template = RequestTemplate {
2756 operation,
2757 path_params: HashMap::new(),
2758 query_params: HashMap::new(),
2759 headers: HashMap::new(),
2760 body: None,
2761 };
2762
2763 let config = K6Config {
2764 target_url: "https://api.example.com".to_string(),
2765 base_path: None,
2766 scenario: LoadScenario::Constant,
2767 duration_secs: 30,
2768 max_vus: 5,
2769 threshold_percentile: "p(95)".to_string(),
2770 threshold_ms: 500,
2771 max_error_rate: 0.05,
2772 auth_header: None,
2773 custom_headers: HashMap::new(),
2774 skip_tls_verify: false,
2775 security_testing_enabled: false,
2776 chunked_request_bodies: false,
2777 target_rps: None,
2778 no_keep_alive: false,
2779 geo_source_ips: Vec::new(),
2780 geo_source_headers: Vec::new(),
2781 };
2782
2783 let generator = K6ScriptGenerator::new(config, vec![template]);
2784 let script = generator.generate().expect("Should generate script");
2785
2786 assert!(
2788 !script.contains("requestUrl"),
2789 "Script should NOT have requestUrl when security is disabled"
2790 );
2791 assert!(
2792 !script.contains("secPayloadGroup"),
2793 "Script should NOT have secPayloadGroup when security is disabled"
2794 );
2795 assert!(
2796 !script.contains("secBodyPayload"),
2797 "Script should NOT have secBodyPayload when security is disabled"
2798 );
2799 }
2800
2801 #[test]
2803 fn test_uses_per_request_cookie_jar() {
2804 use crate::spec_parser::ApiOperation;
2805 use openapiv3::Operation;
2806
2807 let operation = ApiOperation {
2808 method: "get".to_string(),
2809 path: "/api/users".to_string(),
2810 operation: Operation::default(),
2811 operation_id: Some("listUsers".to_string()),
2812 };
2813
2814 let template = RequestTemplate {
2815 operation,
2816 path_params: HashMap::new(),
2817 query_params: HashMap::new(),
2818 headers: HashMap::new(),
2819 body: None,
2820 };
2821
2822 let config = K6Config {
2823 target_url: "https://api.example.com".to_string(),
2824 base_path: None,
2825 scenario: LoadScenario::Constant,
2826 duration_secs: 30,
2827 max_vus: 5,
2828 threshold_percentile: "p(95)".to_string(),
2829 threshold_ms: 500,
2830 max_error_rate: 0.05,
2831 auth_header: None,
2832 custom_headers: HashMap::new(),
2833 skip_tls_verify: false,
2834 security_testing_enabled: false,
2835 chunked_request_bodies: false,
2836 target_rps: None,
2837 no_keep_alive: false,
2838 geo_source_ips: Vec::new(),
2839 geo_source_headers: Vec::new(),
2840 };
2841
2842 let generator = K6ScriptGenerator::new(config, vec![template]);
2843 let script = generator.generate().expect("Should generate script");
2844
2845 assert!(
2847 script.contains("jar: new http.CookieJar()"),
2848 "Script should create fresh CookieJar per request"
2849 );
2850 assert!(
2851 !script.contains("jar: null"),
2852 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2853 );
2854 assert!(
2855 !script.contains("EMPTY_JAR"),
2856 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2857 );
2858 }
2859
2860 #[test]
2864 fn connection_header_forces_http1_comment_and_stays_on_the_wire() {
2865 use crate::spec_parser::ApiOperation;
2866 use openapiv3::Operation;
2867
2868 let operation = ApiOperation {
2869 method: "get".to_string(),
2870 path: "/hop".to_string(),
2871 operation: Operation::default(),
2872 operation_id: Some("hop".to_string()),
2873 };
2874 let mut headers = HashMap::new();
2875 headers.insert("Connection".to_string(), "Transfer-Encoding, keep-alive".to_string());
2876 let template = RequestTemplate {
2877 operation,
2878 path_params: HashMap::new(),
2879 query_params: HashMap::new(),
2880 headers,
2881 body: None,
2882 };
2883 let config = K6Config {
2884 target_url: "https://waf.example.com".to_string(),
2885 base_path: None,
2886 scenario: LoadScenario::Constant,
2887 duration_secs: 30,
2888 max_vus: 1,
2889 threshold_percentile: "p(95)".to_string(),
2890 threshold_ms: 500,
2891 max_error_rate: 0.05,
2892 auth_header: None,
2893 custom_headers: HashMap::new(),
2894 skip_tls_verify: true,
2895 security_testing_enabled: false,
2896 chunked_request_bodies: false,
2897 target_rps: None,
2898 no_keep_alive: false,
2899 geo_source_ips: Vec::new(),
2900 geo_source_headers: Vec::new(),
2901 };
2902 let generator = K6ScriptGenerator::new(config, vec![template]);
2903 assert!(generator.should_force_http1());
2904 let data = generator.build_template_data().expect("template data");
2905 assert!(data.force_http1);
2906 let script = generator.generate().expect("script generates");
2907 assert!(
2908 script.contains("GODEBUG=http2client=0"),
2909 "script must tell a manual k6 run to disable HTTP/2"
2910 );
2911 assert!(
2912 script.contains("Transfer-Encoding, keep-alive"),
2913 "Connection value must stay in the script; stripping it skips the WAF case"
2914 );
2915 assert!(
2916 script.contains("\"Connection\"") || script.contains("Connection"),
2917 "Connection header key must stay on the wire"
2918 );
2919 }
2920
2921 #[test]
2924 fn verbatim_flag_forces_http1_comment_without_connection_header() {
2925 let config = K6Config {
2926 target_url: "https://waf.example.com".to_string(),
2927 base_path: None,
2928 scenario: LoadScenario::Constant,
2929 duration_secs: 30,
2930 max_vus: 1,
2931 threshold_percentile: "p(95)".to_string(),
2932 threshold_ms: 500,
2933 max_error_rate: 0.05,
2934 auth_header: None,
2935 custom_headers: HashMap::new(),
2936 skip_tls_verify: true,
2937 security_testing_enabled: false,
2938 chunked_request_bodies: false,
2939 target_rps: None,
2940 no_keep_alive: false,
2941 geo_source_ips: Vec::new(),
2942 geo_source_headers: Vec::new(),
2943 };
2944 let generator = K6ScriptGenerator::new(config, vec![]).with_force_http1(true);
2945 assert!(generator.should_force_http1());
2946 let script = generator.generate().expect("script generates");
2947 assert!(script.contains("GODEBUG=http2client=0"));
2948 }
2949
2950 #[test]
2952 fn resolve_per_op_metrics_auto_and_explicit() {
2953 let (on, warn) = resolve_per_op_metrics(None, 10, 60);
2954 assert!(on);
2955 assert!(warn.is_none());
2956
2957 let (off, warn) = resolve_per_op_metrics(None, PER_OP_METRICS_AUTO_OPS_THRESHOLD, 60);
2958 assert!(!off);
2959 assert!(warn.as_ref().unwrap().contains("Auto-disabled"));
2960
2961 let (off, warn) = resolve_per_op_metrics(None, 10, PER_OP_METRICS_AUTO_DURATION_SECS);
2962 assert!(!off);
2963 assert!(warn.as_ref().unwrap().contains("duration"));
2964
2965 let (forced_on, warn) =
2966 resolve_per_op_metrics(Some(true), PER_OP_METRICS_AUTO_OPS_THRESHOLD, 86_400);
2967 assert!(forced_on);
2968 assert!(warn.is_none());
2969
2970 let (forced_off, warn) = resolve_per_op_metrics(Some(false), 1, 1);
2971 assert!(!forced_off);
2972 assert!(warn.is_none());
2973 }
2974
2975 #[test]
2976 fn resolve_max_concurrency_auto_caps_huge_specs() {
2977 let (n, warn) = resolve_max_concurrency(None, 10, 50);
2978 assert_eq!(n, MAX_CONCURRENCY_DEFAULT);
2979 assert!(warn.is_none());
2980
2981 let (n, warn) = resolve_max_concurrency(None, HUGE_SPEC_OPS_THRESHOLD, 50);
2982 assert_eq!(n, MAX_CONCURRENCY_HUGE_SPEC);
2983 assert!(warn.as_ref().unwrap().contains("Auto-capped"));
2984
2985 let (n, warn) = resolve_max_concurrency(Some(20), HUGE_SPEC_OPS_THRESHOLD, 50);
2986 assert_eq!(n, 20);
2987 assert!(warn.is_none());
2988
2989 let (n, _) = resolve_max_concurrency(Some(100), 10, 3);
2991 assert_eq!(n, 3);
2992 }
2993
2994 #[test]
2996 fn per_op_metrics_false_omits_trend_rate_declarations() {
2997 use crate::spec_parser::ApiOperation;
2998 use openapiv3::Operation;
2999
3000 let config = K6Config {
3001 target_url: "http://localhost:3000".to_string(),
3002 base_path: None,
3003 scenario: LoadScenario::Constant,
3004 duration_secs: 30,
3005 max_vus: 1,
3006 threshold_percentile: "p(95)".to_string(),
3007 threshold_ms: 500,
3008 max_error_rate: 0.05,
3009 auth_header: None,
3010 custom_headers: HashMap::new(),
3011 skip_tls_verify: false,
3012 security_testing_enabled: false,
3013 chunked_request_bodies: false,
3014 target_rps: None,
3015 no_keep_alive: false,
3016 geo_source_ips: Vec::new(),
3017 geo_source_headers: Vec::new(),
3018 };
3019 let template = RequestTemplate {
3020 operation: ApiOperation {
3021 method: "get".to_string(),
3022 path: "/users".to_string(),
3023 operation: Operation::default(),
3024 operation_id: Some("get_users".to_string()),
3025 },
3026 path_params: HashMap::new(),
3027 query_params: HashMap::new(),
3028 headers: HashMap::new(),
3029 body: None,
3030 };
3031 let on_script = K6ScriptGenerator::new(
3032 K6Config {
3033 target_url: "http://localhost:3000".to_string(),
3034 base_path: None,
3035 scenario: LoadScenario::Constant,
3036 duration_secs: 30,
3037 max_vus: 1,
3038 threshold_percentile: "p(95)".to_string(),
3039 threshold_ms: 500,
3040 max_error_rate: 0.05,
3041 auth_header: None,
3042 custom_headers: HashMap::new(),
3043 skip_tls_verify: false,
3044 security_testing_enabled: false,
3045 chunked_request_bodies: false,
3046 target_rps: None,
3047 no_keep_alive: false,
3048 geo_source_ips: Vec::new(),
3049 geo_source_headers: Vec::new(),
3050 },
3051 vec![template.clone()],
3052 )
3053 .with_per_op_metrics(true)
3054 .generate()
3055 .unwrap();
3056 assert!(
3057 on_script.contains("new Trend(") && on_script.contains("_latency"),
3058 "per_op_metrics=true must emit per-op Trend"
3059 );
3060
3061 let off_script = K6ScriptGenerator::new(config, vec![template])
3062 .with_per_op_metrics(false)
3063 .generate()
3064 .unwrap();
3065 assert!(
3066 off_script.contains("Per-operation Trend/Rate metrics omitted"),
3067 "per_op_metrics=false must document the omission"
3068 );
3069 assert!(
3070 !off_script.contains("get_users_latency") && !off_script.contains("get_users_errors"),
3071 "per_op_metrics=false must not declare per-op Trend/Rate vars"
3072 );
3073 }
3074}