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