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 match base_path {
864 "" | "/" => raw_path.to_string(),
865 bp => {
866 let bp = bp.trim_end_matches('/');
867 if raw_path.starts_with('/') {
868 format!("{}{}", bp, raw_path)
869 } else {
870 format!("{}/{}", bp, raw_path)
871 }
872 }
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn root_base_path_does_not_double_slash() {
882 assert_eq!(
883 join_base_path(
884 "/",
885 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
886 ),
887 "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
888 );
889 assert_eq!(join_base_path("", "/pets"), "/pets");
890 assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
891 assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
892 }
893
894 #[test]
895 fn test_k6_config_creation() {
896 let config = K6Config {
897 target_url: "https://api.example.com".to_string(),
898 base_path: None,
899 scenario: LoadScenario::RampUp,
900 duration_secs: 60,
901 max_vus: 10,
902 threshold_percentile: "p(95)".to_string(),
903 threshold_ms: 500,
904 max_error_rate: 0.05,
905 auth_header: None,
906 custom_headers: HashMap::new(),
907 skip_tls_verify: false,
908 security_testing_enabled: false,
909 chunked_request_bodies: false,
910 target_rps: None,
911 no_keep_alive: false,
912 geo_source_ips: Vec::new(),
913 geo_source_headers: Vec::new(),
914 };
915
916 assert_eq!(config.duration_secs, 60);
917 assert_eq!(config.max_vus, 10);
918 }
919
920 #[test]
921 fn test_script_generator_creation() {
922 let config = K6Config {
923 target_url: "https://api.example.com".to_string(),
924 base_path: None,
925 scenario: LoadScenario::Constant,
926 duration_secs: 30,
927 max_vus: 5,
928 threshold_percentile: "p(95)".to_string(),
929 threshold_ms: 500,
930 max_error_rate: 0.05,
931 auth_header: None,
932 custom_headers: HashMap::new(),
933 skip_tls_verify: false,
934 security_testing_enabled: false,
935 chunked_request_bodies: false,
936 target_rps: None,
937 no_keep_alive: false,
938 geo_source_ips: Vec::new(),
939 geo_source_headers: Vec::new(),
940 };
941
942 let templates = vec![];
943 let generator = K6ScriptGenerator::new(config, templates);
944
945 assert_eq!(generator.templates.len(), 0);
946 }
947
948 #[test]
949 fn colliding_operation_titles_get_unique_const_names() {
950 use crate::spec_parser::ApiOperation;
954 use openapiv3::Operation;
955
956 fn tmpl(id: &str, path: &str) -> RequestTemplate {
957 RequestTemplate {
958 operation: ApiOperation {
959 method: "get".to_string(),
960 path: path.to_string(),
961 operation: Operation::default(),
962 operation_id: Some(id.to_string()),
963 },
964 path_params: HashMap::new(),
965 query_params: HashMap::new(),
966 headers: HashMap::new(),
967 body: None,
968 }
969 }
970
971 let config = K6Config {
972 target_url: "https://example.test".to_string(),
973 base_path: None,
974 scenario: LoadScenario::Constant,
975 duration_secs: 5,
976 max_vus: 1,
977 threshold_percentile: "p(95)".to_string(),
978 threshold_ms: 500,
979 max_error_rate: 0.05,
980 auth_header: None,
981 custom_headers: HashMap::new(),
982 skip_tls_verify: false,
983 security_testing_enabled: false,
984 chunked_request_bodies: false,
985 target_rps: None,
986 no_keep_alive: false,
987 geo_source_ips: Vec::new(),
988 geo_source_headers: Vec::new(),
989 };
990 let generator = K6ScriptGenerator::new(
991 config,
992 vec![
993 tmpl("normal request allowed", "/a"),
994 tmpl("normal request allowed", "/b"),
995 ],
996 );
997 let script = generator.generate().expect("script generates");
998 let latency = script
999 .lines()
1000 .filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
1001 .collect::<Vec<_>>();
1002 assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
1003 assert!(
1004 script.contains("const normal_request_allowed_latency = new Trend"),
1005 "first collision keeps the base name"
1006 );
1007 assert!(
1008 script.contains("const normal_request_allowed_2_latency = new Trend")
1009 || script.contains("const normal_request_allowed_latency_2 = new Trend"),
1010 "second collision must be renamed, script snippet:\n{}",
1011 latency.join("\n")
1012 );
1013 let errors = K6ScriptGenerator::validate_script(&script);
1014 assert!(errors.is_empty(), "validate_script: {errors:#?}");
1015 }
1016
1017 #[test]
1018 fn werkzeug_unc_backslash_x_is_json_encoded_not_template_literal() {
1019 use crate::spec_parser::ApiOperation;
1024 use openapiv3::Operation;
1025
1026 let path = "/static/\\\\attacker.com\\share\\x";
1027 let template = RequestTemplate {
1028 operation: ApiOperation {
1029 method: "get".to_string(),
1030 path: path.to_string(),
1031 operation: Operation::default(),
1032 operation_id: Some("literal UNC double-backslash path blocked".to_string()),
1033 },
1034 path_params: HashMap::new(),
1035 query_params: HashMap::new(),
1036 headers: HashMap::new(),
1037 body: None,
1038 };
1039 let config = K6Config {
1040 target_url: "https://example.test".to_string(),
1041 base_path: None,
1042 scenario: LoadScenario::Constant,
1043 duration_secs: 5,
1044 max_vus: 1,
1045 threshold_percentile: "p(95)".to_string(),
1046 threshold_ms: 500,
1047 max_error_rate: 0.05,
1048 auth_header: None,
1049 custom_headers: HashMap::new(),
1050 skip_tls_verify: false,
1051 security_testing_enabled: false,
1052 chunked_request_bodies: false,
1053 target_rps: None,
1054 no_keep_alive: false,
1055 geo_source_ips: Vec::new(),
1056 geo_source_headers: Vec::new(),
1057 };
1058 let script = K6ScriptGenerator::new(config, vec![template])
1059 .generate()
1060 .expect("script generates");
1061 let encoded = serde_json::to_string(path).expect("path JSON");
1062 assert!(
1063 script.contains(&format!("BASE_URL + {encoded}")),
1064 "expected BASE_URL + {encoded} in script:\n{script}"
1065 );
1066 assert!(
1067 !script.contains("${BASE_URL}/static/"),
1068 "must not dump the raw path into a template literal:\n{script}"
1069 );
1070 let errors = K6ScriptGenerator::validate_script(&script);
1071 assert!(errors.is_empty(), "validate_script: {errors:#?}\n{script}");
1072 }
1073
1074 #[test]
1075 fn validate_script_flags_bare_hex_escape_in_template_literal() {
1076 let bad = r#"
1079import http from 'k6/http';
1080import { check, sleep } from 'k6';
1081import { Rate, Trend } from 'k6/metrics';
1082const t_latency = new Trend('t_latency');
1083export default function() {
1084 const res = http.get(`${BASE_URL}/static/\\attacker.com\share\x`);
1085}
1086"#;
1087 let errors = K6ScriptGenerator::validate_script(bad);
1088 assert!(
1089 errors.iter().any(|e| e.contains("invalid JS hex escape")),
1090 "expected hex-escape error, got {errors:#?}"
1091 );
1092 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
1093 r#"http.get(`${BASE_URL}/static/\\attacker.com\share\x`)"#
1094 )
1095 .is_some());
1096 assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
1097 r#"BASE_URL + "/static/\\\\attacker.com\\share\\x""#
1098 )
1099 .is_none());
1100 }
1101
1102 #[test]
1103 fn test_sanitize_js_identifier() {
1104 assert_eq!(
1106 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
1107 "billing_subscriptions_v1"
1108 );
1109
1110 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
1112
1113 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
1115
1116 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
1118
1119 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
1121
1122 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
1124
1125 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
1127
1128 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
1130 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
1131 assert_eq!(
1132 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
1133 "plans_update_pricing_schemes"
1134 );
1135 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
1136 }
1137
1138 #[test]
1139 fn test_sanitize_k6_metric_name_short_passthrough() {
1140 let short = "billing_subscriptions_list";
1142 let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
1143 assert_eq!(out, short);
1144 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
1145 }
1146
1147 #[test]
1148 fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
1149 let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
1153 charts.workbookChart.axes.categoryAxis.format.line.clear";
1154 let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
1155
1156 assert!(
1158 metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
1159 "metric base len {} exceeded cap {}",
1160 metric.len(),
1161 K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
1162 );
1163
1164 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1166 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
1167 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
1168 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
1170 }
1171
1172 #[test]
1173 fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
1174 let prefix = "a".repeat(150);
1177 let a = format!("{prefix}.foo");
1178 let b = format!("{prefix}.bar");
1179 let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
1180 let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
1181 assert_ne!(ma, mb, "distinct long names produced the same metric name");
1182 }
1183
1184 #[test]
1185 fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
1186 let long = format!("{}123end", "x".repeat(120));
1188 let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
1189 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1190 }
1191
1192 #[test]
1193 fn test_microsoft_graph_long_operation_id_passes_validation() {
1194 use crate::spec_parser::ApiOperation;
1197 use openapiv3::Operation;
1198
1199 let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
1200 workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
1201 line.clear";
1202
1203 let operation = ApiOperation {
1204 method: "post".to_string(),
1205 path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
1206 operation: Operation::default(),
1207 operation_id: Some(long_op_id.to_string()),
1208 };
1209 let template = RequestTemplate {
1210 operation,
1211 path_params: HashMap::new(),
1212 query_params: HashMap::new(),
1213 headers: HashMap::new(),
1214 body: None,
1215 };
1216 let config = K6Config {
1217 target_url: "https://api.example.com".to_string(),
1218 base_path: Some("/v1.0".to_string()),
1219 scenario: LoadScenario::Constant,
1220 duration_secs: 30,
1221 max_vus: 5,
1222 threshold_percentile: "p(95)".to_string(),
1223 threshold_ms: 500,
1224 max_error_rate: 0.05,
1225 auth_header: None,
1226 custom_headers: HashMap::new(),
1227 skip_tls_verify: false,
1228 security_testing_enabled: false,
1229 chunked_request_bodies: false,
1230 target_rps: None,
1231 no_keep_alive: false,
1232 geo_source_ips: Vec::new(),
1233 geo_source_headers: Vec::new(),
1234 };
1235 let generator = K6ScriptGenerator::new(config, vec![template]);
1236 let script = generator.generate().expect("script generates");
1237
1238 let errors = K6ScriptGenerator::validate_script(&script);
1239 assert!(
1240 errors.is_empty(),
1241 "validate_script returned errors for long operationId: {errors:#?}"
1242 );
1243 }
1244
1245 #[test]
1250 fn test_abort_valve_opt_out_and_rate() {
1251 fn base_config() -> K6Config {
1252 K6Config {
1253 target_url: "https://api.example.com".to_string(),
1254 base_path: None,
1255 scenario: LoadScenario::Constant,
1256 duration_secs: 30,
1257 max_vus: 5,
1258 threshold_percentile: "p(95)".to_string(),
1259 threshold_ms: 500,
1260 max_error_rate: 0.05,
1261 auth_header: None,
1262 custom_headers: HashMap::new(),
1263 skip_tls_verify: false,
1264 security_testing_enabled: false,
1265 chunked_request_bodies: false,
1266 target_rps: None,
1267 no_keep_alive: false,
1268 geo_source_ips: Vec::new(),
1269 geo_source_headers: Vec::new(),
1270 }
1271 }
1272
1273 let default_script = K6ScriptGenerator::new(base_config(), vec![])
1275 .generate()
1276 .expect("script generates");
1277 assert!(
1278 default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
1279 "default script must keep the 0.95 abort valve"
1280 );
1281
1282 let stress_script = K6ScriptGenerator::new(base_config(), vec![])
1285 .with_abort_valve(false, 0.95)
1286 .generate()
1287 .expect("script generates");
1288 assert!(
1291 !stress_script.contains("abortOnFail: true"),
1292 "--no-abort-on-error must drop the abortOnFail threshold"
1293 );
1294 assert!(stress_script.contains("rate<0.05"));
1296
1297 let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
1299 .with_abort_valve(true, 0.99)
1300 .generate()
1301 .expect("script generates");
1302 assert!(
1303 tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
1304 "--abort-on-error-rate must retune the valve threshold"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_script_generation_with_dots_in_name() {
1310 use crate::spec_parser::ApiOperation;
1311 use openapiv3::Operation;
1312
1313 let operation = ApiOperation {
1315 method: "get".to_string(),
1316 path: "/billing/subscriptions".to_string(),
1317 operation: Operation::default(),
1318 operation_id: Some("billing.subscriptions.v1".to_string()),
1319 };
1320
1321 let template = RequestTemplate {
1322 operation,
1323 path_params: HashMap::new(),
1324 query_params: HashMap::new(),
1325 headers: HashMap::new(),
1326 body: None,
1327 };
1328
1329 let config = K6Config {
1330 target_url: "https://api.example.com".to_string(),
1331 base_path: None,
1332 scenario: LoadScenario::Constant,
1333 duration_secs: 30,
1334 max_vus: 5,
1335 threshold_percentile: "p(95)".to_string(),
1336 threshold_ms: 500,
1337 max_error_rate: 0.05,
1338 auth_header: None,
1339 custom_headers: HashMap::new(),
1340 skip_tls_verify: false,
1341 security_testing_enabled: false,
1342 chunked_request_bodies: false,
1343 target_rps: None,
1344 no_keep_alive: false,
1345 geo_source_ips: Vec::new(),
1346 geo_source_headers: Vec::new(),
1347 };
1348
1349 let generator = K6ScriptGenerator::new(config, vec![template]);
1350 let script = generator.generate().expect("Should generate script");
1351
1352 assert!(
1354 script.contains("const billing_subscriptions_v1_latency"),
1355 "Script should contain sanitized variable name for latency"
1356 );
1357 assert!(
1358 script.contains("const billing_subscriptions_v1_errors"),
1359 "Script should contain sanitized variable name for errors"
1360 );
1361
1362 assert!(
1365 !script.contains("const billing.subscriptions"),
1366 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
1367 );
1368
1369 assert!(
1372 script.contains("'billing_subscriptions_v1_latency'"),
1373 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1374 );
1375 assert!(
1376 script.contains("'billing_subscriptions_v1_errors'"),
1377 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1378 );
1379
1380 assert!(
1382 script.contains("billing.subscriptions.v1"),
1383 "Script should contain original name in comments/strings for readability"
1384 );
1385
1386 assert!(
1388 script.contains("billing_subscriptions_v1_latency.add"),
1389 "Variable usage should use sanitized name"
1390 );
1391 assert!(
1392 script.contains("billing_subscriptions_v1_errors.add"),
1393 "Variable usage should use sanitized name"
1394 );
1395 }
1396
1397 #[test]
1404 fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
1405 use crate::spec_parser::ApiOperation;
1406 use openapiv3::Operation;
1407
1408 let operation = ApiOperation {
1409 method: "get".to_string(),
1410 path: "/users".to_string(),
1411 operation: Operation::default(),
1412 operation_id: Some("listUsers".to_string()),
1413 };
1414 let template = RequestTemplate {
1415 operation,
1416 path_params: HashMap::new(),
1417 query_params: HashMap::new(),
1418 headers: HashMap::new(),
1419 body: None,
1420 };
1421
1422 let config = K6Config {
1423 target_url: "https://api.example.com".to_string(),
1424 base_path: None,
1425 scenario: LoadScenario::RampUp,
1426 duration_secs: 600,
1427 max_vus: 100,
1428 threshold_percentile: "p(95)".to_string(),
1429 threshold_ms: 500,
1430 max_error_rate: 0.05,
1431 auth_header: None,
1432 custom_headers: HashMap::new(),
1433 skip_tls_verify: false,
1434 security_testing_enabled: false,
1435 chunked_request_bodies: false,
1436 target_rps: Some(100),
1437 no_keep_alive: false,
1438 geo_source_ips: Vec::new(),
1439 geo_source_headers: Vec::new(),
1440 };
1441
1442 let generator = K6ScriptGenerator::new(config, vec![template]);
1443 let script = generator.generate().expect("Should generate script");
1444
1445 assert!(
1446 script.contains("constant-arrival-rate"),
1447 "with --rps set, executor must switch to constant-arrival-rate"
1448 );
1449 assert!(
1450 script.contains("rate: 100,"),
1451 "constant-arrival-rate must use the configured --rps as `rate`"
1452 );
1453 assert!(
1454 script.contains("duration: '600s'"),
1455 "duration must come from --duration, not the ramp-down stage; got:\n{}",
1456 script
1457 );
1458 assert!(
1459 script.contains("preAllocatedVUs: 100,"),
1460 "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
1461 script
1462 );
1463 assert!(
1464 script.contains("maxVUs: 100,"),
1465 "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
1466 script
1467 );
1468 for (idx, line) in script.lines().enumerate() {
1472 let trimmed = line.trim_start();
1473 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1474 continue;
1475 }
1476 assert!(
1477 !trimmed.starts_with("preAllocatedVUs: 0"),
1478 "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
1479 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
1480 idx + 1,
1481 line,
1482 );
1483 }
1484 }
1485
1486 #[test]
1489 fn test_cps_sets_no_connection_reuse() {
1490 use crate::spec_parser::ApiOperation;
1491 use openapiv3::Operation;
1492
1493 let operation = ApiOperation {
1494 method: "get".to_string(),
1495 path: "/u".to_string(),
1496 operation: Operation::default(),
1497 operation_id: Some("u".to_string()),
1498 };
1499 let template = RequestTemplate {
1500 operation,
1501 path_params: HashMap::new(),
1502 query_params: HashMap::new(),
1503 headers: HashMap::new(),
1504 body: None,
1505 };
1506 let config = K6Config {
1507 target_url: "https://api.example.com".to_string(),
1508 base_path: None,
1509 scenario: LoadScenario::Constant,
1510 duration_secs: 30,
1511 max_vus: 5,
1512 threshold_percentile: "p(95)".to_string(),
1513 threshold_ms: 500,
1514 max_error_rate: 0.05,
1515 auth_header: None,
1516 custom_headers: HashMap::new(),
1517 skip_tls_verify: false,
1518 security_testing_enabled: false,
1519 chunked_request_bodies: false,
1520 target_rps: None,
1521 no_keep_alive: true,
1522 geo_source_ips: Vec::new(),
1523 geo_source_headers: Vec::new(),
1524 };
1525 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1526 assert!(
1527 script.contains("noConnectionReuse: true"),
1528 "--cps must set noConnectionReuse: true on the k6 options block"
1529 );
1530 assert!(
1531 script.contains("Total Connections:"),
1532 "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1533 );
1534 assert!(
1535 script.contains("Connection Rate:"),
1536 "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1537 );
1538 }
1539
1540 #[test]
1546 fn test_constant_scenario_starts_at_target_vus() {
1547 use crate::spec_parser::ApiOperation;
1548 use openapiv3::Operation;
1549
1550 let operation = ApiOperation {
1551 method: "get".to_string(),
1552 path: "/u".to_string(),
1553 operation: Operation::default(),
1554 operation_id: Some("u".to_string()),
1555 };
1556 let template = RequestTemplate {
1557 operation,
1558 path_params: HashMap::new(),
1559 query_params: HashMap::new(),
1560 headers: HashMap::new(),
1561 body: None,
1562 };
1563 let config = K6Config {
1564 target_url: "https://api.example.com".to_string(),
1565 base_path: None,
1566 scenario: LoadScenario::Constant,
1567 duration_secs: 600,
1568 max_vus: 5,
1569 threshold_percentile: "p(95)".to_string(),
1570 threshold_ms: 500,
1571 max_error_rate: 0.05,
1572 auth_header: None,
1573 custom_headers: HashMap::new(),
1574 skip_tls_verify: false,
1575 security_testing_enabled: false,
1576 chunked_request_bodies: false,
1577 target_rps: None,
1578 no_keep_alive: false,
1579 geo_source_ips: Vec::new(),
1580 geo_source_headers: Vec::new(),
1581 };
1582 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1583 assert!(
1584 script.contains("startVUs: 5,"),
1585 "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1586 script
1587 );
1588 let ramp_config = K6Config {
1590 target_url: "https://api.example.com".to_string(),
1591 base_path: None,
1592 scenario: LoadScenario::RampUp,
1593 duration_secs: 600,
1594 max_vus: 5,
1595 threshold_percentile: "p(95)".to_string(),
1596 threshold_ms: 500,
1597 max_error_rate: 0.05,
1598 auth_header: None,
1599 custom_headers: HashMap::new(),
1600 skip_tls_verify: false,
1601 security_testing_enabled: false,
1602 chunked_request_bodies: false,
1603 target_rps: None,
1604 no_keep_alive: false,
1605 geo_source_ips: Vec::new(),
1606 geo_source_headers: Vec::new(),
1607 };
1608 let ramp_template = RequestTemplate {
1609 operation: ApiOperation {
1610 method: "get".to_string(),
1611 path: "/u".to_string(),
1612 operation: Operation::default(),
1613 operation_id: Some("u".to_string()),
1614 },
1615 path_params: HashMap::new(),
1616 query_params: HashMap::new(),
1617 headers: HashMap::new(),
1618 body: None,
1619 };
1620 let ramp_script =
1621 K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1622 assert!(
1623 ramp_script.contains("startVUs: 0,"),
1624 "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1625 ramp_script
1626 );
1627 }
1628
1629 #[test]
1638 fn test_connections_opened_counter_present() {
1639 use crate::spec_parser::ApiOperation;
1640 use openapiv3::Operation;
1641
1642 let operation = ApiOperation {
1643 method: "get".to_string(),
1644 path: "/u".to_string(),
1645 operation: Operation::default(),
1646 operation_id: Some("u".to_string()),
1647 };
1648 let template = RequestTemplate {
1649 operation,
1650 path_params: HashMap::new(),
1651 query_params: HashMap::new(),
1652 headers: HashMap::new(),
1653 body: None,
1654 };
1655 let config = K6Config {
1656 target_url: "https://api.example.com".to_string(),
1657 base_path: None,
1658 scenario: LoadScenario::Constant,
1659 duration_secs: 30,
1660 max_vus: 5,
1661 threshold_percentile: "p(95)".to_string(),
1662 threshold_ms: 500,
1663 max_error_rate: 0.05,
1664 auth_header: None,
1665 custom_headers: HashMap::new(),
1666 skip_tls_verify: false,
1667 security_testing_enabled: false,
1668 chunked_request_bodies: false,
1669 target_rps: Some(50),
1670 no_keep_alive: false,
1671 geo_source_ips: Vec::new(),
1672 geo_source_headers: Vec::new(),
1673 };
1674 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1675 assert!(
1676 script.contains("new Counter('mockforge_connections_opened')"),
1677 "template must declare the mockforge_connections_opened Counter"
1678 );
1679 assert!(
1680 script.contains("mockforge_connections_opened.add(1)"),
1681 "template must increment mockforge_connections_opened on new TCP connect"
1682 );
1683 assert!(
1684 script.contains("res.timings.connecting > 0"),
1685 "template must gate the connection-opened increment on \
1686 res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_validate_script_valid() {
1692 let valid_script = r#"
1693import http from 'k6/http';
1694import { check, sleep } from 'k6';
1695import { Rate, Trend } from 'k6/metrics';
1696
1697const test_latency = new Trend('test_latency');
1698const test_errors = new Rate('test_errors');
1699
1700export default function() {
1701 const res = http.get('https://example.com');
1702 test_latency.add(res.timings.duration);
1703 test_errors.add(res.status !== 200);
1704}
1705"#;
1706
1707 let errors = K6ScriptGenerator::validate_script(valid_script);
1708 assert!(errors.is_empty(), "Valid script should have no validation errors");
1709 }
1710
1711 #[test]
1712 fn test_validate_script_invalid_metric_name() {
1713 let invalid_script = r#"
1714import http from 'k6/http';
1715import { check, sleep } from 'k6';
1716import { Rate, Trend } from 'k6/metrics';
1717
1718const test_latency = new Trend('test.latency');
1719const test_errors = new Rate('test_errors');
1720
1721export default function() {
1722 const res = http.get('https://example.com');
1723 test_latency.add(res.timings.duration);
1724}
1725"#;
1726
1727 let errors = K6ScriptGenerator::validate_script(invalid_script);
1728 assert!(
1729 !errors.is_empty(),
1730 "Script with invalid metric name should have validation errors"
1731 );
1732 assert!(
1733 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1734 "Should detect invalid metric name with dot"
1735 );
1736 }
1737
1738 #[test]
1739 fn test_validate_script_missing_imports() {
1740 let invalid_script = r#"
1741const test_latency = new Trend('test_latency');
1742export default function() {}
1743"#;
1744
1745 let errors = K6ScriptGenerator::validate_script(invalid_script);
1746 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1747 }
1748
1749 #[test]
1750 fn test_validate_script_metric_name_validation() {
1751 let valid_script = r#"
1754import http from 'k6/http';
1755import { check, sleep } from 'k6';
1756import { Rate, Trend } from 'k6/metrics';
1757const test_latency = new Trend('test_latency');
1758const test_errors = new Rate('test_errors');
1759export default function() {}
1760"#;
1761 let errors = K6ScriptGenerator::validate_script(valid_script);
1762 assert!(errors.is_empty(), "Valid metric names should pass validation");
1763
1764 let invalid_cases = vec![
1766 ("test.latency", "dot in metric name"),
1767 ("123test", "starts with number"),
1768 ("test-latency", "hyphen in metric name"),
1769 ("test@latency", "special character"),
1770 ];
1771
1772 for (invalid_name, description) in invalid_cases {
1773 let script = format!(
1774 r#"
1775import http from 'k6/http';
1776import {{ check, sleep }} from 'k6';
1777import {{ Rate, Trend }} from 'k6/metrics';
1778const test_latency = new Trend('{}');
1779export default function() {{}}
1780"#,
1781 invalid_name
1782 );
1783 let errors = K6ScriptGenerator::validate_script(&script);
1784 assert!(
1785 !errors.is_empty(),
1786 "Metric name '{}' ({}) should fail validation",
1787 invalid_name,
1788 description
1789 );
1790 }
1791 }
1792
1793 #[test]
1794 fn test_skip_tls_verify_with_body() {
1795 use crate::spec_parser::ApiOperation;
1796 use openapiv3::Operation;
1797 use serde_json::json;
1798
1799 let operation = ApiOperation {
1801 method: "post".to_string(),
1802 path: "/api/users".to_string(),
1803 operation: Operation::default(),
1804 operation_id: Some("createUser".to_string()),
1805 };
1806
1807 let template = RequestTemplate {
1808 operation,
1809 path_params: HashMap::new(),
1810 query_params: HashMap::new(),
1811 headers: HashMap::new(),
1812 body: Some(json!({"name": "test"})),
1813 };
1814
1815 let config = K6Config {
1816 target_url: "https://api.example.com".to_string(),
1817 base_path: None,
1818 scenario: LoadScenario::Constant,
1819 duration_secs: 30,
1820 max_vus: 5,
1821 threshold_percentile: "p(95)".to_string(),
1822 threshold_ms: 500,
1823 max_error_rate: 0.05,
1824 auth_header: None,
1825 custom_headers: HashMap::new(),
1826 skip_tls_verify: true,
1827 security_testing_enabled: false,
1828 chunked_request_bodies: false,
1829 target_rps: None,
1830 no_keep_alive: false,
1831 geo_source_ips: Vec::new(),
1832 geo_source_headers: Vec::new(),
1833 };
1834
1835 let generator = K6ScriptGenerator::new(config, vec![template]);
1836 let script = generator.generate().expect("Should generate script");
1837
1838 assert!(
1840 script.contains("insecureSkipTLSVerify: true"),
1841 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_skip_tls_verify_without_body() {
1847 use crate::spec_parser::ApiOperation;
1848 use openapiv3::Operation;
1849
1850 let operation = ApiOperation {
1852 method: "get".to_string(),
1853 path: "/api/users".to_string(),
1854 operation: Operation::default(),
1855 operation_id: Some("getUsers".to_string()),
1856 };
1857
1858 let template = RequestTemplate {
1859 operation,
1860 path_params: HashMap::new(),
1861 query_params: HashMap::new(),
1862 headers: HashMap::new(),
1863 body: None,
1864 };
1865
1866 let config = K6Config {
1867 target_url: "https://api.example.com".to_string(),
1868 base_path: None,
1869 scenario: LoadScenario::Constant,
1870 duration_secs: 30,
1871 max_vus: 5,
1872 threshold_percentile: "p(95)".to_string(),
1873 threshold_ms: 500,
1874 max_error_rate: 0.05,
1875 auth_header: None,
1876 custom_headers: HashMap::new(),
1877 skip_tls_verify: true,
1878 security_testing_enabled: false,
1879 chunked_request_bodies: false,
1880 target_rps: None,
1881 no_keep_alive: false,
1882 geo_source_ips: Vec::new(),
1883 geo_source_headers: Vec::new(),
1884 };
1885
1886 let generator = K6ScriptGenerator::new(config, vec![template]);
1887 let script = generator.generate().expect("Should generate script");
1888
1889 assert!(
1891 script.contains("insecureSkipTLSVerify: true"),
1892 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_no_skip_tls_verify() {
1898 use crate::spec_parser::ApiOperation;
1899 use openapiv3::Operation;
1900
1901 let operation = ApiOperation {
1903 method: "get".to_string(),
1904 path: "/api/users".to_string(),
1905 operation: Operation::default(),
1906 operation_id: Some("getUsers".to_string()),
1907 };
1908
1909 let template = RequestTemplate {
1910 operation,
1911 path_params: HashMap::new(),
1912 query_params: HashMap::new(),
1913 headers: HashMap::new(),
1914 body: None,
1915 };
1916
1917 let config = K6Config {
1918 target_url: "https://api.example.com".to_string(),
1919 base_path: None,
1920 scenario: LoadScenario::Constant,
1921 duration_secs: 30,
1922 max_vus: 5,
1923 threshold_percentile: "p(95)".to_string(),
1924 threshold_ms: 500,
1925 max_error_rate: 0.05,
1926 auth_header: None,
1927 custom_headers: HashMap::new(),
1928 skip_tls_verify: false,
1929 security_testing_enabled: false,
1930 chunked_request_bodies: false,
1931 target_rps: None,
1932 no_keep_alive: false,
1933 geo_source_ips: Vec::new(),
1934 geo_source_headers: Vec::new(),
1935 };
1936
1937 let generator = K6ScriptGenerator::new(config, vec![template]);
1938 let script = generator.generate().expect("Should generate script");
1939
1940 assert!(
1942 !script.contains("insecureSkipTLSVerify"),
1943 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_skip_tls_verify_multiple_operations() {
1949 use crate::spec_parser::ApiOperation;
1950 use openapiv3::Operation;
1951 use serde_json::json;
1952
1953 let operation1 = ApiOperation {
1955 method: "get".to_string(),
1956 path: "/api/users".to_string(),
1957 operation: Operation::default(),
1958 operation_id: Some("getUsers".to_string()),
1959 };
1960
1961 let operation2 = ApiOperation {
1962 method: "post".to_string(),
1963 path: "/api/users".to_string(),
1964 operation: Operation::default(),
1965 operation_id: Some("createUser".to_string()),
1966 };
1967
1968 let template1 = RequestTemplate {
1969 operation: operation1,
1970 path_params: HashMap::new(),
1971 query_params: HashMap::new(),
1972 headers: HashMap::new(),
1973 body: None,
1974 };
1975
1976 let template2 = RequestTemplate {
1977 operation: operation2,
1978 path_params: HashMap::new(),
1979 query_params: HashMap::new(),
1980 headers: HashMap::new(),
1981 body: Some(json!({"name": "test"})),
1982 };
1983
1984 let config = K6Config {
1985 target_url: "https://api.example.com".to_string(),
1986 base_path: None,
1987 scenario: LoadScenario::Constant,
1988 duration_secs: 30,
1989 max_vus: 5,
1990 threshold_percentile: "p(95)".to_string(),
1991 threshold_ms: 500,
1992 max_error_rate: 0.05,
1993 auth_header: None,
1994 custom_headers: HashMap::new(),
1995 skip_tls_verify: true,
1996 security_testing_enabled: false,
1997 chunked_request_bodies: false,
1998 target_rps: None,
1999 no_keep_alive: false,
2000 geo_source_ips: Vec::new(),
2001 geo_source_headers: Vec::new(),
2002 };
2003
2004 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
2005 let script = generator.generate().expect("Should generate script");
2006
2007 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
2010 assert_eq!(
2011 skip_count, 1,
2012 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
2013 );
2014
2015 let options_start = script.find("export const options = {").expect("Should have options");
2017 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
2018 let options_prefix = &script[options_start..scenarios_start];
2019 assert!(
2020 options_prefix.contains("insecureSkipTLSVerify: true"),
2021 "insecureSkipTLSVerify should be in global options block"
2022 );
2023 }
2024
2025 #[test]
2026 fn test_dynamic_params_in_body() {
2027 use crate::spec_parser::ApiOperation;
2028 use openapiv3::Operation;
2029 use serde_json::json;
2030
2031 let operation = ApiOperation {
2033 method: "post".to_string(),
2034 path: "/api/resources".to_string(),
2035 operation: Operation::default(),
2036 operation_id: Some("createResource".to_string()),
2037 };
2038
2039 let template = RequestTemplate {
2040 operation,
2041 path_params: HashMap::new(),
2042 query_params: HashMap::new(),
2043 headers: HashMap::new(),
2044 body: Some(json!({
2045 "name": "load-test-${__VU}",
2046 "iteration": "${__ITER}"
2047 })),
2048 };
2049
2050 let config = K6Config {
2051 target_url: "https://api.example.com".to_string(),
2052 base_path: None,
2053 scenario: LoadScenario::Constant,
2054 duration_secs: 30,
2055 max_vus: 5,
2056 threshold_percentile: "p(95)".to_string(),
2057 threshold_ms: 500,
2058 max_error_rate: 0.05,
2059 auth_header: None,
2060 custom_headers: HashMap::new(),
2061 skip_tls_verify: false,
2062 security_testing_enabled: false,
2063 chunked_request_bodies: false,
2064 target_rps: None,
2065 no_keep_alive: false,
2066 geo_source_ips: Vec::new(),
2067 geo_source_headers: Vec::new(),
2068 };
2069
2070 let generator = K6ScriptGenerator::new(config, vec![template]);
2071 let script = generator.generate().expect("Should generate script");
2072
2073 assert!(
2075 script.contains("Dynamic body with runtime placeholders"),
2076 "Script should contain comment about dynamic body"
2077 );
2078
2079 assert!(
2081 script.contains("__VU"),
2082 "Script should contain __VU reference for dynamic VU-based values"
2083 );
2084
2085 assert!(
2087 script.contains("__ITER"),
2088 "Script should contain __ITER reference for dynamic iteration values"
2089 );
2090 }
2091
2092 #[test]
2093 fn test_dynamic_params_with_uuid() {
2094 use crate::spec_parser::ApiOperation;
2095 use openapiv3::Operation;
2096 use serde_json::json;
2097
2098 let operation = ApiOperation {
2100 method: "post".to_string(),
2101 path: "/api/resources".to_string(),
2102 operation: Operation::default(),
2103 operation_id: Some("createResource".to_string()),
2104 };
2105
2106 let template = RequestTemplate {
2107 operation,
2108 path_params: HashMap::new(),
2109 query_params: HashMap::new(),
2110 headers: HashMap::new(),
2111 body: Some(json!({
2112 "id": "${__UUID}"
2113 })),
2114 };
2115
2116 let config = K6Config {
2117 target_url: "https://api.example.com".to_string(),
2118 base_path: None,
2119 scenario: LoadScenario::Constant,
2120 duration_secs: 30,
2121 max_vus: 5,
2122 threshold_percentile: "p(95)".to_string(),
2123 threshold_ms: 500,
2124 max_error_rate: 0.05,
2125 auth_header: None,
2126 custom_headers: HashMap::new(),
2127 skip_tls_verify: false,
2128 security_testing_enabled: false,
2129 chunked_request_bodies: false,
2130 target_rps: None,
2131 no_keep_alive: false,
2132 geo_source_ips: Vec::new(),
2133 geo_source_headers: Vec::new(),
2134 };
2135
2136 let generator = K6ScriptGenerator::new(config, vec![template]);
2137 let script = generator.generate().expect("Should generate script");
2138
2139 assert!(
2142 !script.contains("k6/experimental/webcrypto"),
2143 "Script should NOT include deprecated k6/experimental/webcrypto import"
2144 );
2145
2146 assert!(
2148 script.contains("crypto.randomUUID()"),
2149 "Script should contain crypto.randomUUID() for UUID placeholder"
2150 );
2151 }
2152
2153 #[test]
2154 fn test_dynamic_params_with_counter() {
2155 use crate::spec_parser::ApiOperation;
2156 use openapiv3::Operation;
2157 use serde_json::json;
2158
2159 let operation = ApiOperation {
2161 method: "post".to_string(),
2162 path: "/api/resources".to_string(),
2163 operation: Operation::default(),
2164 operation_id: Some("createResource".to_string()),
2165 };
2166
2167 let template = RequestTemplate {
2168 operation,
2169 path_params: HashMap::new(),
2170 query_params: HashMap::new(),
2171 headers: HashMap::new(),
2172 body: Some(json!({
2173 "sequence": "${__COUNTER}"
2174 })),
2175 };
2176
2177 let config = K6Config {
2178 target_url: "https://api.example.com".to_string(),
2179 base_path: None,
2180 scenario: LoadScenario::Constant,
2181 duration_secs: 30,
2182 max_vus: 5,
2183 threshold_percentile: "p(95)".to_string(),
2184 threshold_ms: 500,
2185 max_error_rate: 0.05,
2186 auth_header: None,
2187 custom_headers: HashMap::new(),
2188 skip_tls_verify: false,
2189 security_testing_enabled: false,
2190 chunked_request_bodies: false,
2191 target_rps: None,
2192 no_keep_alive: false,
2193 geo_source_ips: Vec::new(),
2194 geo_source_headers: Vec::new(),
2195 };
2196
2197 let generator = K6ScriptGenerator::new(config, vec![template]);
2198 let script = generator.generate().expect("Should generate script");
2199
2200 assert!(
2202 script.contains("let globalCounter = 0"),
2203 "Script should include globalCounter initialization when COUNTER placeholder is used"
2204 );
2205
2206 assert!(
2208 script.contains("globalCounter++"),
2209 "Script should contain globalCounter++ for COUNTER placeholder"
2210 );
2211 }
2212
2213 #[test]
2214 fn test_static_body_no_dynamic_marker() {
2215 use crate::spec_parser::ApiOperation;
2216 use openapiv3::Operation;
2217 use serde_json::json;
2218
2219 let operation = ApiOperation {
2221 method: "post".to_string(),
2222 path: "/api/resources".to_string(),
2223 operation: Operation::default(),
2224 operation_id: Some("createResource".to_string()),
2225 };
2226
2227 let template = RequestTemplate {
2228 operation,
2229 path_params: HashMap::new(),
2230 query_params: HashMap::new(),
2231 headers: HashMap::new(),
2232 body: Some(json!({
2233 "name": "static-value",
2234 "count": 42
2235 })),
2236 };
2237
2238 let config = K6Config {
2239 target_url: "https://api.example.com".to_string(),
2240 base_path: None,
2241 scenario: LoadScenario::Constant,
2242 duration_secs: 30,
2243 max_vus: 5,
2244 threshold_percentile: "p(95)".to_string(),
2245 threshold_ms: 500,
2246 max_error_rate: 0.05,
2247 auth_header: None,
2248 custom_headers: HashMap::new(),
2249 skip_tls_verify: false,
2250 security_testing_enabled: false,
2251 chunked_request_bodies: false,
2252 target_rps: None,
2253 no_keep_alive: false,
2254 geo_source_ips: Vec::new(),
2255 geo_source_headers: Vec::new(),
2256 };
2257
2258 let generator = K6ScriptGenerator::new(config, vec![template]);
2259 let script = generator.generate().expect("Should generate script");
2260
2261 assert!(
2263 !script.contains("Dynamic body with runtime placeholders"),
2264 "Script should NOT contain dynamic body comment for static body"
2265 );
2266
2267 assert!(
2269 !script.contains("webcrypto"),
2270 "Script should NOT include webcrypto import for static body"
2271 );
2272
2273 assert!(
2275 !script.contains("let globalCounter"),
2276 "Script should NOT include globalCounter for static body"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_security_testing_enabled_generates_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: true,
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 contain getNextSecurityPayload() call when security_testing_enabled is true"
2328 );
2329 assert!(
2330 script.contains("applySecurityPayload"),
2331 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
2332 );
2333 assert!(
2334 script.contains("secPayloadGroup"),
2335 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
2336 );
2337 assert!(
2338 script.contains("secBodyPayload"),
2339 "Script should contain secBodyPayload variable when security_testing_enabled is true"
2340 );
2341 assert!(
2343 script.contains("hasSecCookie"),
2344 "Script should track hasSecCookie for CookieJar conflict avoidance"
2345 );
2346 assert!(
2347 script.contains("secRequestOpts"),
2348 "Script should use secRequestOpts to conditionally skip CookieJar"
2349 );
2350 assert!(
2352 script.contains("const requestHeaders = { ..."),
2353 "Script should spread headers into mutable copy for security payload injection"
2354 );
2355 assert!(
2357 script.contains("secPayload.injectAsPath"),
2358 "Script should check injectAsPath for path-based URI injection"
2359 );
2360 assert!(
2362 script.contains("secBodyPayload.formBody"),
2363 "Script should check formBody for form-encoded body delivery"
2364 );
2365 assert!(
2366 script.contains("application/x-www-form-urlencoded"),
2367 "Script should set Content-Type for form-encoded body"
2368 );
2369 let op_comment_pos =
2371 script.find("// Operation 0:").expect("Should have Operation 0 comment");
2372 let sec_payload_pos = script
2373 .find("const secPayloadGroup = typeof getNextSecurityPayload")
2374 .expect("Should have secPayloadGroup assignment");
2375 assert!(
2376 sec_payload_pos > op_comment_pos,
2377 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
2378 );
2379 }
2380
2381 #[test]
2382 fn test_security_testing_disabled_no_calling_code() {
2383 use crate::spec_parser::ApiOperation;
2384 use openapiv3::Operation;
2385 use serde_json::json;
2386
2387 let operation = ApiOperation {
2388 method: "post".to_string(),
2389 path: "/api/users".to_string(),
2390 operation: Operation::default(),
2391 operation_id: Some("createUser".to_string()),
2392 };
2393
2394 let template = RequestTemplate {
2395 operation,
2396 path_params: HashMap::new(),
2397 query_params: HashMap::new(),
2398 headers: HashMap::new(),
2399 body: Some(json!({"name": "test"})),
2400 };
2401
2402 let config = K6Config {
2403 target_url: "https://api.example.com".to_string(),
2404 base_path: None,
2405 scenario: LoadScenario::Constant,
2406 duration_secs: 30,
2407 max_vus: 5,
2408 threshold_percentile: "p(95)".to_string(),
2409 threshold_ms: 500,
2410 max_error_rate: 0.05,
2411 auth_header: None,
2412 custom_headers: HashMap::new(),
2413 skip_tls_verify: false,
2414 security_testing_enabled: false,
2415 chunked_request_bodies: false,
2416 target_rps: None,
2417 no_keep_alive: false,
2418 geo_source_ips: Vec::new(),
2419 geo_source_headers: Vec::new(),
2420 };
2421
2422 let generator = K6ScriptGenerator::new(config, vec![template]);
2423 let script = generator.generate().expect("Should generate script");
2424
2425 assert!(
2427 !script.contains("getNextSecurityPayload"),
2428 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
2429 );
2430 assert!(
2431 !script.contains("applySecurityPayload"),
2432 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
2433 );
2434 assert!(
2435 !script.contains("secPayloadGroup"),
2436 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
2437 );
2438 assert!(
2439 !script.contains("secBodyPayload"),
2440 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
2441 );
2442 assert!(
2443 !script.contains("hasSecCookie"),
2444 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
2445 );
2446 assert!(
2447 !script.contains("secRequestOpts"),
2448 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
2449 );
2450 assert!(
2451 !script.contains("injectAsPath"),
2452 "Script should NOT contain injectAsPath when security_testing_enabled is false"
2453 );
2454 assert!(
2455 !script.contains("formBody"),
2456 "Script should NOT contain formBody when security_testing_enabled is false"
2457 );
2458 }
2459
2460 #[test]
2464 fn test_security_e2e_definitions_and_calls_both_present() {
2465 use crate::security_payloads::{
2466 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
2467 };
2468 use crate::spec_parser::ApiOperation;
2469 use openapiv3::Operation;
2470 use serde_json::json;
2471
2472 let operation = ApiOperation {
2474 method: "post".to_string(),
2475 path: "/api/users".to_string(),
2476 operation: Operation::default(),
2477 operation_id: Some("createUser".to_string()),
2478 };
2479
2480 let template = RequestTemplate {
2481 operation,
2482 path_params: HashMap::new(),
2483 query_params: HashMap::new(),
2484 headers: HashMap::new(),
2485 body: Some(json!({"name": "test"})),
2486 };
2487
2488 let config = K6Config {
2489 target_url: "https://api.example.com".to_string(),
2490 base_path: None,
2491 scenario: LoadScenario::Constant,
2492 duration_secs: 30,
2493 max_vus: 5,
2494 threshold_percentile: "p(95)".to_string(),
2495 threshold_ms: 500,
2496 max_error_rate: 0.05,
2497 auth_header: None,
2498 custom_headers: HashMap::new(),
2499 skip_tls_verify: false,
2500 security_testing_enabled: true,
2501 chunked_request_bodies: false,
2502 target_rps: None,
2503 no_keep_alive: false,
2504 geo_source_ips: Vec::new(),
2505 geo_source_headers: Vec::new(),
2506 };
2507
2508 let generator = K6ScriptGenerator::new(config, vec![template]);
2509 let mut script = generator.generate().expect("Should generate base script");
2510
2511 let security_config = SecurityTestConfig::default().enable();
2513 let payloads = SecurityPayloads::get_payloads(&security_config);
2514 assert!(!payloads.is_empty(), "Should have built-in payloads");
2515
2516 let mut additional_code = String::new();
2517 additional_code
2518 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
2519 additional_code.push('\n');
2520 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
2521 additional_code.push('\n');
2522
2523 if let Some(pos) = script.find("export const options") {
2525 script.insert_str(
2526 pos,
2527 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
2528 );
2529 }
2530
2531 assert!(
2534 script.contains("function getNextSecurityPayload()"),
2535 "Final script must contain getNextSecurityPayload function DEFINITION"
2536 );
2537 assert!(
2538 script.contains("function applySecurityPayload("),
2539 "Final script must contain applySecurityPayload function DEFINITION"
2540 );
2541 assert!(
2542 script.contains("securityPayloads"),
2543 "Final script must contain securityPayloads array"
2544 );
2545
2546 assert!(
2548 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2549 "Final script must contain secPayloadGroup assignment (template calling code)"
2550 );
2551 assert!(
2552 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2553 "Final script must contain applySecurityPayload CALL with secBodyPayload"
2554 );
2555 assert!(
2556 script.contains("const requestHeaders = { ..."),
2557 "Final script must spread headers for security payload header injection"
2558 );
2559 assert!(
2560 script.contains("for (const secPayload of secPayloadGroup)"),
2561 "Final script must loop over secPayloadGroup"
2562 );
2563 assert!(
2564 script.contains("secPayload.injectAsPath"),
2565 "Final script must check injectAsPath for path-based URI injection"
2566 );
2567 assert!(
2568 script.contains("secBodyPayload.formBody"),
2569 "Final script must check formBody for form-encoded body delivery"
2570 );
2571
2572 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2574 let call_pos =
2575 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2576 let options_pos = script.find("export const options").unwrap();
2577 let default_fn_pos = script.find("export default function").unwrap();
2578
2579 assert!(
2580 def_pos < options_pos,
2581 "Function definitions must appear before export const options"
2582 );
2583 assert!(
2584 call_pos > default_fn_pos,
2585 "Calling code must appear inside export default function"
2586 );
2587 }
2588
2589 #[test]
2591 fn test_security_uri_injection_for_get_requests() {
2592 use crate::spec_parser::ApiOperation;
2593 use openapiv3::Operation;
2594
2595 let operation = ApiOperation {
2596 method: "get".to_string(),
2597 path: "/api/users".to_string(),
2598 operation: Operation::default(),
2599 operation_id: Some("listUsers".to_string()),
2600 };
2601
2602 let template = RequestTemplate {
2603 operation,
2604 path_params: HashMap::new(),
2605 query_params: HashMap::new(),
2606 headers: HashMap::new(),
2607 body: None,
2608 };
2609
2610 let config = K6Config {
2611 target_url: "https://api.example.com".to_string(),
2612 base_path: None,
2613 scenario: LoadScenario::Constant,
2614 duration_secs: 30,
2615 max_vus: 5,
2616 threshold_percentile: "p(95)".to_string(),
2617 threshold_ms: 500,
2618 max_error_rate: 0.05,
2619 auth_header: None,
2620 custom_headers: HashMap::new(),
2621 skip_tls_verify: false,
2622 security_testing_enabled: true,
2623 chunked_request_bodies: false,
2624 target_rps: None,
2625 no_keep_alive: false,
2626 geo_source_ips: Vec::new(),
2627 geo_source_headers: Vec::new(),
2628 };
2629
2630 let generator = K6ScriptGenerator::new(config, vec![template]);
2631 let script = generator.generate().expect("Should generate script");
2632
2633 assert!(
2635 script.contains("requestUrl"),
2636 "Script should build requestUrl variable for URI payload injection"
2637 );
2638 assert!(
2639 script.contains("secPayload.location === 'uri'"),
2640 "Script should check for URI-location payloads"
2641 );
2642 assert!(
2644 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2645 "Script should URL-encode security payload in query string for valid HTTP"
2646 );
2647 assert!(
2649 script.contains("secPayload.injectAsPath"),
2650 "Script should check injectAsPath for path-based URI injection"
2651 );
2652 assert!(
2653 script.contains("encodeURI(secPayload.payload)"),
2654 "Script should use encodeURI for path-based injection"
2655 );
2656 assert!(
2658 script.contains("http.get(requestUrl,"),
2659 "GET request should use requestUrl (with URI injection) instead of inline URL"
2660 );
2661 }
2662
2663 #[test]
2665 fn test_security_uri_injection_for_post_requests() {
2666 use crate::spec_parser::ApiOperation;
2667 use openapiv3::Operation;
2668 use serde_json::json;
2669
2670 let operation = ApiOperation {
2671 method: "post".to_string(),
2672 path: "/api/users".to_string(),
2673 operation: Operation::default(),
2674 operation_id: Some("createUser".to_string()),
2675 };
2676
2677 let template = RequestTemplate {
2678 operation,
2679 path_params: HashMap::new(),
2680 query_params: HashMap::new(),
2681 headers: HashMap::new(),
2682 body: Some(json!({"name": "test"})),
2683 };
2684
2685 let config = K6Config {
2686 target_url: "https://api.example.com".to_string(),
2687 base_path: None,
2688 scenario: LoadScenario::Constant,
2689 duration_secs: 30,
2690 max_vus: 5,
2691 threshold_percentile: "p(95)".to_string(),
2692 threshold_ms: 500,
2693 max_error_rate: 0.05,
2694 auth_header: None,
2695 custom_headers: HashMap::new(),
2696 skip_tls_verify: false,
2697 security_testing_enabled: true,
2698 chunked_request_bodies: false,
2699 target_rps: None,
2700 no_keep_alive: false,
2701 geo_source_ips: Vec::new(),
2702 geo_source_headers: Vec::new(),
2703 };
2704
2705 let generator = K6ScriptGenerator::new(config, vec![template]);
2706 let script = generator.generate().expect("Should generate script");
2707
2708 assert!(
2710 script.contains("requestUrl"),
2711 "POST script should build requestUrl for URI payload injection"
2712 );
2713 assert!(
2714 script.contains("secPayload.location === 'uri'"),
2715 "POST script should check for URI-location payloads"
2716 );
2717 assert!(
2718 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2719 "POST script should apply security body payload to request body"
2720 );
2721 assert!(
2723 script.contains("http.post(requestUrl,"),
2724 "POST request should use requestUrl (with URI injection) instead of inline URL"
2725 );
2726 }
2727
2728 #[test]
2730 fn test_no_uri_injection_when_security_disabled() {
2731 use crate::spec_parser::ApiOperation;
2732 use openapiv3::Operation;
2733
2734 let operation = ApiOperation {
2735 method: "get".to_string(),
2736 path: "/api/users".to_string(),
2737 operation: Operation::default(),
2738 operation_id: Some("listUsers".to_string()),
2739 };
2740
2741 let template = RequestTemplate {
2742 operation,
2743 path_params: HashMap::new(),
2744 query_params: HashMap::new(),
2745 headers: HashMap::new(),
2746 body: None,
2747 };
2748
2749 let config = K6Config {
2750 target_url: "https://api.example.com".to_string(),
2751 base_path: None,
2752 scenario: LoadScenario::Constant,
2753 duration_secs: 30,
2754 max_vus: 5,
2755 threshold_percentile: "p(95)".to_string(),
2756 threshold_ms: 500,
2757 max_error_rate: 0.05,
2758 auth_header: None,
2759 custom_headers: HashMap::new(),
2760 skip_tls_verify: false,
2761 security_testing_enabled: false,
2762 chunked_request_bodies: false,
2763 target_rps: None,
2764 no_keep_alive: false,
2765 geo_source_ips: Vec::new(),
2766 geo_source_headers: Vec::new(),
2767 };
2768
2769 let generator = K6ScriptGenerator::new(config, vec![template]);
2770 let script = generator.generate().expect("Should generate script");
2771
2772 assert!(
2774 !script.contains("requestUrl"),
2775 "Script should NOT have requestUrl when security is disabled"
2776 );
2777 assert!(
2778 !script.contains("secPayloadGroup"),
2779 "Script should NOT have secPayloadGroup when security is disabled"
2780 );
2781 assert!(
2782 !script.contains("secBodyPayload"),
2783 "Script should NOT have secBodyPayload when security is disabled"
2784 );
2785 }
2786
2787 #[test]
2789 fn test_uses_per_request_cookie_jar() {
2790 use crate::spec_parser::ApiOperation;
2791 use openapiv3::Operation;
2792
2793 let operation = ApiOperation {
2794 method: "get".to_string(),
2795 path: "/api/users".to_string(),
2796 operation: Operation::default(),
2797 operation_id: Some("listUsers".to_string()),
2798 };
2799
2800 let template = RequestTemplate {
2801 operation,
2802 path_params: HashMap::new(),
2803 query_params: HashMap::new(),
2804 headers: HashMap::new(),
2805 body: None,
2806 };
2807
2808 let config = K6Config {
2809 target_url: "https://api.example.com".to_string(),
2810 base_path: None,
2811 scenario: LoadScenario::Constant,
2812 duration_secs: 30,
2813 max_vus: 5,
2814 threshold_percentile: "p(95)".to_string(),
2815 threshold_ms: 500,
2816 max_error_rate: 0.05,
2817 auth_header: None,
2818 custom_headers: HashMap::new(),
2819 skip_tls_verify: false,
2820 security_testing_enabled: false,
2821 chunked_request_bodies: false,
2822 target_rps: None,
2823 no_keep_alive: false,
2824 geo_source_ips: Vec::new(),
2825 geo_source_headers: Vec::new(),
2826 };
2827
2828 let generator = K6ScriptGenerator::new(config, vec![template]);
2829 let script = generator.generate().expect("Should generate script");
2830
2831 assert!(
2833 script.contains("jar: new http.CookieJar()"),
2834 "Script should create fresh CookieJar per request"
2835 );
2836 assert!(
2837 !script.contains("jar: null"),
2838 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2839 );
2840 assert!(
2841 !script.contains("EMPTY_JAR"),
2842 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2843 );
2844 }
2845
2846 #[test]
2850 fn connection_header_forces_http1_comment_and_stays_on_the_wire() {
2851 use crate::spec_parser::ApiOperation;
2852 use openapiv3::Operation;
2853
2854 let operation = ApiOperation {
2855 method: "get".to_string(),
2856 path: "/hop".to_string(),
2857 operation: Operation::default(),
2858 operation_id: Some("hop".to_string()),
2859 };
2860 let mut headers = HashMap::new();
2861 headers.insert("Connection".to_string(), "Transfer-Encoding, keep-alive".to_string());
2862 let template = RequestTemplate {
2863 operation,
2864 path_params: HashMap::new(),
2865 query_params: HashMap::new(),
2866 headers,
2867 body: None,
2868 };
2869 let config = K6Config {
2870 target_url: "https://waf.example.com".to_string(),
2871 base_path: None,
2872 scenario: LoadScenario::Constant,
2873 duration_secs: 30,
2874 max_vus: 1,
2875 threshold_percentile: "p(95)".to_string(),
2876 threshold_ms: 500,
2877 max_error_rate: 0.05,
2878 auth_header: None,
2879 custom_headers: HashMap::new(),
2880 skip_tls_verify: true,
2881 security_testing_enabled: false,
2882 chunked_request_bodies: false,
2883 target_rps: None,
2884 no_keep_alive: false,
2885 geo_source_ips: Vec::new(),
2886 geo_source_headers: Vec::new(),
2887 };
2888 let generator = K6ScriptGenerator::new(config, vec![template]);
2889 assert!(generator.should_force_http1());
2890 let data = generator.build_template_data().expect("template data");
2891 assert!(data.force_http1);
2892 let script = generator.generate().expect("script generates");
2893 assert!(
2894 script.contains("GODEBUG=http2client=0"),
2895 "script must tell a manual k6 run to disable HTTP/2"
2896 );
2897 assert!(
2898 script.contains("Transfer-Encoding, keep-alive"),
2899 "Connection value must stay in the script; stripping it skips the WAF case"
2900 );
2901 assert!(
2902 script.contains("\"Connection\"") || script.contains("Connection"),
2903 "Connection header key must stay on the wire"
2904 );
2905 }
2906
2907 #[test]
2910 fn verbatim_flag_forces_http1_comment_without_connection_header() {
2911 let config = K6Config {
2912 target_url: "https://waf.example.com".to_string(),
2913 base_path: None,
2914 scenario: LoadScenario::Constant,
2915 duration_secs: 30,
2916 max_vus: 1,
2917 threshold_percentile: "p(95)".to_string(),
2918 threshold_ms: 500,
2919 max_error_rate: 0.05,
2920 auth_header: None,
2921 custom_headers: HashMap::new(),
2922 skip_tls_verify: true,
2923 security_testing_enabled: false,
2924 chunked_request_bodies: false,
2925 target_rps: None,
2926 no_keep_alive: false,
2927 geo_source_ips: Vec::new(),
2928 geo_source_headers: Vec::new(),
2929 };
2930 let generator = K6ScriptGenerator::new(config, vec![]).with_force_http1(true);
2931 assert!(generator.should_force_http1());
2932 let script = generator.generate().expect("script generates");
2933 assert!(script.contains("GODEBUG=http2client=0"));
2934 }
2935
2936 #[test]
2938 fn resolve_per_op_metrics_auto_and_explicit() {
2939 let (on, warn) = resolve_per_op_metrics(None, 10, 60);
2940 assert!(on);
2941 assert!(warn.is_none());
2942
2943 let (off, warn) = resolve_per_op_metrics(None, PER_OP_METRICS_AUTO_OPS_THRESHOLD, 60);
2944 assert!(!off);
2945 assert!(warn.as_ref().unwrap().contains("Auto-disabled"));
2946
2947 let (off, warn) = resolve_per_op_metrics(None, 10, PER_OP_METRICS_AUTO_DURATION_SECS);
2948 assert!(!off);
2949 assert!(warn.as_ref().unwrap().contains("duration"));
2950
2951 let (forced_on, warn) =
2952 resolve_per_op_metrics(Some(true), PER_OP_METRICS_AUTO_OPS_THRESHOLD, 86_400);
2953 assert!(forced_on);
2954 assert!(warn.is_none());
2955
2956 let (forced_off, warn) = resolve_per_op_metrics(Some(false), 1, 1);
2957 assert!(!forced_off);
2958 assert!(warn.is_none());
2959 }
2960
2961 #[test]
2962 fn resolve_max_concurrency_auto_caps_huge_specs() {
2963 let (n, warn) = resolve_max_concurrency(None, 10, 50);
2964 assert_eq!(n, MAX_CONCURRENCY_DEFAULT);
2965 assert!(warn.is_none());
2966
2967 let (n, warn) = resolve_max_concurrency(None, HUGE_SPEC_OPS_THRESHOLD, 50);
2968 assert_eq!(n, MAX_CONCURRENCY_HUGE_SPEC);
2969 assert!(warn.as_ref().unwrap().contains("Auto-capped"));
2970
2971 let (n, warn) = resolve_max_concurrency(Some(20), HUGE_SPEC_OPS_THRESHOLD, 50);
2972 assert_eq!(n, 20);
2973 assert!(warn.is_none());
2974
2975 let (n, _) = resolve_max_concurrency(Some(100), 10, 3);
2977 assert_eq!(n, 3);
2978 }
2979
2980 #[test]
2982 fn per_op_metrics_false_omits_trend_rate_declarations() {
2983 use crate::spec_parser::ApiOperation;
2984 use openapiv3::Operation;
2985
2986 let config = K6Config {
2987 target_url: "http://localhost:3000".to_string(),
2988 base_path: None,
2989 scenario: LoadScenario::Constant,
2990 duration_secs: 30,
2991 max_vus: 1,
2992 threshold_percentile: "p(95)".to_string(),
2993 threshold_ms: 500,
2994 max_error_rate: 0.05,
2995 auth_header: None,
2996 custom_headers: HashMap::new(),
2997 skip_tls_verify: false,
2998 security_testing_enabled: false,
2999 chunked_request_bodies: false,
3000 target_rps: None,
3001 no_keep_alive: false,
3002 geo_source_ips: Vec::new(),
3003 geo_source_headers: Vec::new(),
3004 };
3005 let template = RequestTemplate {
3006 operation: ApiOperation {
3007 method: "get".to_string(),
3008 path: "/users".to_string(),
3009 operation: Operation::default(),
3010 operation_id: Some("get_users".to_string()),
3011 },
3012 path_params: HashMap::new(),
3013 query_params: HashMap::new(),
3014 headers: HashMap::new(),
3015 body: None,
3016 };
3017 let on_script = K6ScriptGenerator::new(
3018 K6Config {
3019 target_url: "http://localhost:3000".to_string(),
3020 base_path: None,
3021 scenario: LoadScenario::Constant,
3022 duration_secs: 30,
3023 max_vus: 1,
3024 threshold_percentile: "p(95)".to_string(),
3025 threshold_ms: 500,
3026 max_error_rate: 0.05,
3027 auth_header: None,
3028 custom_headers: HashMap::new(),
3029 skip_tls_verify: false,
3030 security_testing_enabled: false,
3031 chunked_request_bodies: false,
3032 target_rps: None,
3033 no_keep_alive: false,
3034 geo_source_ips: Vec::new(),
3035 geo_source_headers: Vec::new(),
3036 },
3037 vec![template.clone()],
3038 )
3039 .with_per_op_metrics(true)
3040 .generate()
3041 .unwrap();
3042 assert!(
3043 on_script.contains("new Trend(") && on_script.contains("_latency"),
3044 "per_op_metrics=true must emit per-op Trend"
3045 );
3046
3047 let off_script = K6ScriptGenerator::new(config, vec![template])
3048 .with_per_op_metrics(false)
3049 .generate()
3050 .unwrap();
3051 assert!(
3052 off_script.contains("Per-operation Trend/Rate metrics omitted"),
3053 "per_op_metrics=false must document the omission"
3054 );
3055 assert!(
3056 !off_script.contains("get_users_latency") && !off_script.contains("get_users_errors"),
3057 "per_op_metrics=false must not declare per-op Trend/Rate vars"
3058 );
3059 }
3060}