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;
9#[cfg(test)]
10use serde_json::json;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, Serialize)]
20pub struct K6ScriptTemplateData {
21 pub base_url: String,
22 pub stages: Vec<K6StageData>,
23 pub operations: Vec<K6OperationData>,
24 pub threshold_percentile: String,
25 pub threshold_ms: u64,
26 pub max_error_rate: f64,
27 pub scenario_name: String,
28 pub skip_tls_verify: bool,
29 pub has_dynamic_values: bool,
30 pub dynamic_imports: Vec<String>,
31 pub dynamic_globals: Vec<String>,
32 pub security_testing_enabled: bool,
33 pub has_custom_headers: bool,
34 pub chunked_request_bodies: bool,
42 pub target_rps: Option<u32>,
46 pub no_keep_alive: bool,
50 pub duration_secs: u64,
57 pub max_vus: u32,
60}
61
62#[derive(Debug, Clone, Serialize)]
64pub struct K6CrudFlowTemplateData {
65 pub base_url: String,
66 pub flows: Vec<Value>,
67 pub extract_fields: Vec<String>,
68 pub duration_secs: u64,
69 pub max_vus: u32,
70 pub auth_header: Option<String>,
71 pub custom_headers: HashMap<String, String>,
72 pub skip_tls_verify: bool,
73 pub stages: Vec<K6StageData>,
74 pub threshold_percentile: String,
75 pub threshold_ms: u64,
76 pub max_error_rate: f64,
77 pub headers: String,
79 pub dynamic_imports: Vec<String>,
80 pub dynamic_globals: Vec<String>,
81 pub extracted_values_output_path: String,
82 pub error_injection_enabled: bool,
83 pub error_rate: f64,
84 pub error_types: Vec<String>,
85 pub security_testing_enabled: bool,
86 pub has_custom_headers: bool,
87}
88
89#[derive(Debug, Clone, Serialize)]
91pub struct K6StageData {
92 pub duration: String,
93 pub target: u32,
94}
95
96#[derive(Debug, Clone, Serialize)]
98pub struct K6OperationData {
99 pub index: usize,
100 pub name: String,
101 pub metric_name: String,
102 pub display_name: String,
103 pub method: String,
104 pub path: Value,
105 pub path_is_dynamic: bool,
106 pub headers: Value,
107 pub body: Option<Value>,
108 pub body_is_dynamic: bool,
109 pub has_body: bool,
110 pub is_get_or_head: bool,
111}
112
113pub struct K6Config {
115 pub target_url: String,
116 pub base_path: Option<String>,
119 pub scenario: LoadScenario,
120 pub duration_secs: u64,
121 pub max_vus: u32,
122 pub threshold_percentile: String,
123 pub threshold_ms: u64,
124 pub max_error_rate: f64,
125 pub auth_header: Option<String>,
126 pub custom_headers: HashMap<String, String>,
127 pub skip_tls_verify: bool,
128 pub security_testing_enabled: bool,
129 pub chunked_request_bodies: bool,
132 pub target_rps: Option<u32>,
135 pub no_keep_alive: bool,
138}
139
140pub struct K6ScriptGenerator {
142 config: K6Config,
143 templates: Vec<RequestTemplate>,
144}
145
146impl K6ScriptGenerator {
147 pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
149 Self { config, templates }
150 }
151
152 pub fn generate(&self) -> Result<String> {
154 let handlebars = Handlebars::new();
155
156 let template = include_str!("templates/k6_script.hbs");
157
158 let data = self.build_template_data()?;
159
160 let value = serde_json::to_value(&data)
161 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
162
163 handlebars
164 .render_template(template, &value)
165 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
166 }
167
168 const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
174
175 pub fn sanitize_k6_metric_name(name: &str) -> String {
188 let sanitized = Self::sanitize_js_identifier(name);
189 if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
190 return sanitized;
191 }
192
193 use std::collections::hash_map::DefaultHasher;
194 use std::hash::{Hash, Hasher};
195 let mut hasher = DefaultHasher::new();
196 name.hash(&mut hasher);
200 let hash_suffix = format!("{:08x}", hasher.finish() as u32);
201
202 let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
204 let prefix = &sanitized[..prefix_len];
205 let prefix = prefix.trim_end_matches('_');
207 format!("{}_{}", prefix, hash_suffix)
208 }
209
210 pub fn sanitize_js_identifier(name: &str) -> String {
220 let mut result = String::new();
221 let mut chars = name.chars().peekable();
222
223 if let Some(&first) = chars.peek() {
225 if first.is_ascii_digit() {
226 result.push('_');
227 }
228 }
229
230 for ch in chars {
231 if ch.is_ascii_alphanumeric() || ch == '_' {
232 result.push(ch);
233 } else {
234 if !result.ends_with('_') {
237 result.push('_');
238 }
239 }
240 }
241
242 result = result.trim_end_matches('_').to_string();
244
245 if result.is_empty() {
247 result = "operation".to_string();
248 }
249
250 result
251 }
252
253 fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
255 let stages = self
256 .config
257 .scenario
258 .generate_stages(self.config.duration_secs, self.config.max_vus);
259
260 let base_path = self.config.base_path.as_deref().unwrap_or("");
262
263 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
265
266 let operations = self
267 .templates
268 .iter()
269 .enumerate()
270 .map(|(idx, template)| {
271 let display_name = template.operation.display_name();
272 let sanitized_name = Self::sanitize_js_identifier(&display_name);
273 let metric_name = Self::sanitize_k6_metric_name(&display_name);
279 let k6_method = match template.operation.method.to_lowercase().as_str() {
281 "delete" => "del".to_string(),
282 m => m.to_string(),
283 };
284 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
287
288 let raw_path = template.generate_path();
291 let full_path = if base_path.is_empty() {
292 raw_path
293 } else {
294 format!("{}{}", base_path, raw_path)
295 };
296 let processed_path = DynamicParamProcessor::process_path(&full_path);
297 all_placeholders.extend(processed_path.placeholders.clone());
298
299 let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
301 let processed_body = DynamicParamProcessor::process_json_body(body);
302 all_placeholders.extend(processed_body.placeholders.clone());
303 (Some(processed_body.value), processed_body.is_dynamic)
304 } else {
305 (None, false)
306 };
307
308 let path_value = if processed_path.is_dynamic {
309 processed_path.value
310 } else {
311 full_path
312 };
313
314 K6OperationData {
315 index: idx,
316 name: sanitized_name,
317 metric_name,
318 display_name,
319 method: k6_method,
320 path: Value::String(path_value),
321 path_is_dynamic: processed_path.is_dynamic,
322 headers: Value::String(self.build_headers_json(template)),
323 body: body_value.map(Value::String),
324 body_is_dynamic,
325 has_body: template.body.is_some(),
326 is_get_or_head,
327 }
328 })
329 .collect::<Vec<_>>();
330
331 let required_imports: Vec<String> =
333 DynamicParamProcessor::get_required_imports(&all_placeholders)
334 .into_iter()
335 .map(String::from)
336 .collect();
337 let required_globals: Vec<String> =
338 DynamicParamProcessor::get_required_globals(&all_placeholders)
339 .into_iter()
340 .map(String::from)
341 .collect();
342 let has_dynamic_values = !all_placeholders.is_empty();
343
344 Ok(K6ScriptTemplateData {
345 base_url: self.config.target_url.clone(),
346 stages: stages
347 .iter()
348 .map(|s| K6StageData {
349 duration: s.duration.clone(),
350 target: s.target,
351 })
352 .collect(),
353 operations,
354 threshold_percentile: self.config.threshold_percentile.clone(),
355 threshold_ms: self.config.threshold_ms,
356 max_error_rate: self.config.max_error_rate,
357 scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
358 skip_tls_verify: self.config.skip_tls_verify,
359 has_dynamic_values,
360 dynamic_imports: required_imports,
361 dynamic_globals: required_globals,
362 security_testing_enabled: self.config.security_testing_enabled,
363 has_custom_headers: !self.config.custom_headers.is_empty(),
364 chunked_request_bodies: self.config.chunked_request_bodies,
365 target_rps: self.config.target_rps,
366 no_keep_alive: self.config.no_keep_alive,
367 duration_secs: self.config.duration_secs,
368 max_vus: self.config.max_vus,
369 })
370 }
371
372 fn build_headers_json(&self, template: &RequestTemplate) -> String {
374 let mut headers = template.get_headers();
375
376 if let Some(auth) = &self.config.auth_header {
378 headers.insert("Authorization".to_string(), auth.clone());
379 }
380
381 for (key, value) in &self.config.custom_headers {
383 headers.insert(key.clone(), value.clone());
384 }
385
386 if self.config.chunked_request_bodies && template.body.is_some() {
391 headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
392 }
393
394 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
396 }
397
398 pub fn validate_script(script: &str) -> Vec<String> {
407 let mut errors = Vec::new();
408
409 if !script.contains("import http from 'k6/http'") {
411 errors.push("Missing required import: 'k6/http'".to_string());
412 }
413 if !script.contains("import { check") && !script.contains("import {check") {
414 errors.push("Missing required import: 'check' from 'k6'".to_string());
415 }
416 if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
417 errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
418 }
419
420 let lines: Vec<&str> = script.lines().collect();
424 for (line_num, line) in lines.iter().enumerate() {
425 let trimmed = line.trim();
426
427 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
429 if let Some(start) = trimmed.find('\'') {
432 if let Some(end) = trimmed[start + 1..].find('\'') {
433 let metric_name = &trimmed[start + 1..start + 1 + end];
434 if !Self::is_valid_k6_metric_name(metric_name) {
435 errors.push(format!(
436 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
437 line_num + 1,
438 metric_name
439 ));
440 }
441 }
442 } else if let Some(start) = trimmed.find('"') {
443 if let Some(end) = trimmed[start + 1..].find('"') {
444 let metric_name = &trimmed[start + 1..start + 1 + end];
445 if !Self::is_valid_k6_metric_name(metric_name) {
446 errors.push(format!(
447 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
448 line_num + 1,
449 metric_name
450 ));
451 }
452 }
453 }
454 }
455
456 if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
458 if let Some(equals_pos) = trimmed.find('=') {
459 let var_decl = &trimmed[..equals_pos];
460 if var_decl.contains('.')
463 && !var_decl.contains("'")
464 && !var_decl.contains("\"")
465 && !var_decl.trim().starts_with("//")
466 {
467 errors.push(format!(
468 "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
469 line_num + 1,
470 var_decl.trim()
471 ));
472 }
473 }
474 }
475 }
476
477 errors
478 }
479
480 fn is_valid_k6_metric_name(name: &str) -> bool {
487 if name.is_empty() || name.len() > 128 {
488 return false;
489 }
490
491 let mut chars = name.chars();
492
493 if let Some(first) = chars.next() {
495 if !first.is_ascii_alphabetic() && first != '_' {
496 return false;
497 }
498 }
499
500 for ch in chars {
502 if !ch.is_ascii_alphanumeric() && ch != '_' {
503 return false;
504 }
505 }
506
507 true
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_k6_config_creation() {
517 let config = K6Config {
518 target_url: "https://api.example.com".to_string(),
519 base_path: None,
520 scenario: LoadScenario::RampUp,
521 duration_secs: 60,
522 max_vus: 10,
523 threshold_percentile: "p(95)".to_string(),
524 threshold_ms: 500,
525 max_error_rate: 0.05,
526 auth_header: None,
527 custom_headers: HashMap::new(),
528 skip_tls_verify: false,
529 security_testing_enabled: false,
530 chunked_request_bodies: false,
531 target_rps: None,
532 no_keep_alive: false,
533 };
534
535 assert_eq!(config.duration_secs, 60);
536 assert_eq!(config.max_vus, 10);
537 }
538
539 #[test]
540 fn test_script_generator_creation() {
541 let config = K6Config {
542 target_url: "https://api.example.com".to_string(),
543 base_path: None,
544 scenario: LoadScenario::Constant,
545 duration_secs: 30,
546 max_vus: 5,
547 threshold_percentile: "p(95)".to_string(),
548 threshold_ms: 500,
549 max_error_rate: 0.05,
550 auth_header: None,
551 custom_headers: HashMap::new(),
552 skip_tls_verify: false,
553 security_testing_enabled: false,
554 chunked_request_bodies: false,
555 target_rps: None,
556 no_keep_alive: false,
557 };
558
559 let templates = vec![];
560 let generator = K6ScriptGenerator::new(config, templates);
561
562 assert_eq!(generator.templates.len(), 0);
563 }
564
565 #[test]
566 fn test_sanitize_js_identifier() {
567 assert_eq!(
569 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
570 "billing_subscriptions_v1"
571 );
572
573 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
575
576 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
578
579 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
581
582 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
584
585 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
587
588 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
590
591 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
593 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
594 assert_eq!(
595 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
596 "plans_update_pricing_schemes"
597 );
598 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
599 }
600
601 #[test]
602 fn test_sanitize_k6_metric_name_short_passthrough() {
603 let short = "billing_subscriptions_list";
605 let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
606 assert_eq!(out, short);
607 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
608 }
609
610 #[test]
611 fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
612 let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
616 charts.workbookChart.axes.categoryAxis.format.line.clear";
617 let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
618
619 assert!(
621 metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
622 "metric base len {} exceeded cap {}",
623 metric.len(),
624 K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
625 );
626
627 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
629 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
630 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
631 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
633 }
634
635 #[test]
636 fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
637 let prefix = "a".repeat(150);
640 let a = format!("{prefix}.foo");
641 let b = format!("{prefix}.bar");
642 let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
643 let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
644 assert_ne!(ma, mb, "distinct long names produced the same metric name");
645 }
646
647 #[test]
648 fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
649 let long = format!("{}123end", "x".repeat(120));
651 let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
652 assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
653 }
654
655 #[test]
656 fn test_microsoft_graph_long_operation_id_passes_validation() {
657 use crate::spec_parser::ApiOperation;
660 use openapiv3::Operation;
661
662 let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
663 workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
664 line.clear";
665
666 let operation = ApiOperation {
667 method: "post".to_string(),
668 path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
669 operation: Operation::default(),
670 operation_id: Some(long_op_id.to_string()),
671 };
672 let template = RequestTemplate {
673 operation,
674 path_params: HashMap::new(),
675 query_params: HashMap::new(),
676 headers: HashMap::new(),
677 body: None,
678 };
679 let config = K6Config {
680 target_url: "https://api.example.com".to_string(),
681 base_path: Some("/v1.0".to_string()),
682 scenario: LoadScenario::Constant,
683 duration_secs: 30,
684 max_vus: 5,
685 threshold_percentile: "p(95)".to_string(),
686 threshold_ms: 500,
687 max_error_rate: 0.05,
688 auth_header: None,
689 custom_headers: HashMap::new(),
690 skip_tls_verify: false,
691 security_testing_enabled: false,
692 chunked_request_bodies: false,
693 target_rps: None,
694 no_keep_alive: false,
695 };
696 let generator = K6ScriptGenerator::new(config, vec![template]);
697 let script = generator.generate().expect("script generates");
698
699 let errors = K6ScriptGenerator::validate_script(&script);
700 assert!(
701 errors.is_empty(),
702 "validate_script returned errors for long operationId: {errors:#?}"
703 );
704 }
705
706 #[test]
707 fn test_script_generation_with_dots_in_name() {
708 use crate::spec_parser::ApiOperation;
709 use openapiv3::Operation;
710
711 let operation = ApiOperation {
713 method: "get".to_string(),
714 path: "/billing/subscriptions".to_string(),
715 operation: Operation::default(),
716 operation_id: Some("billing.subscriptions.v1".to_string()),
717 };
718
719 let template = RequestTemplate {
720 operation,
721 path_params: HashMap::new(),
722 query_params: HashMap::new(),
723 headers: HashMap::new(),
724 body: None,
725 };
726
727 let config = K6Config {
728 target_url: "https://api.example.com".to_string(),
729 base_path: None,
730 scenario: LoadScenario::Constant,
731 duration_secs: 30,
732 max_vus: 5,
733 threshold_percentile: "p(95)".to_string(),
734 threshold_ms: 500,
735 max_error_rate: 0.05,
736 auth_header: None,
737 custom_headers: HashMap::new(),
738 skip_tls_verify: false,
739 security_testing_enabled: false,
740 chunked_request_bodies: false,
741 target_rps: None,
742 no_keep_alive: false,
743 };
744
745 let generator = K6ScriptGenerator::new(config, vec![template]);
746 let script = generator.generate().expect("Should generate script");
747
748 assert!(
750 script.contains("const billing_subscriptions_v1_latency"),
751 "Script should contain sanitized variable name for latency"
752 );
753 assert!(
754 script.contains("const billing_subscriptions_v1_errors"),
755 "Script should contain sanitized variable name for errors"
756 );
757
758 assert!(
761 !script.contains("const billing.subscriptions"),
762 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
763 );
764
765 assert!(
768 script.contains("'billing_subscriptions_v1_latency'"),
769 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
770 );
771 assert!(
772 script.contains("'billing_subscriptions_v1_errors'"),
773 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
774 );
775
776 assert!(
778 script.contains("billing.subscriptions.v1"),
779 "Script should contain original name in comments/strings for readability"
780 );
781
782 assert!(
784 script.contains("billing_subscriptions_v1_latency.add"),
785 "Variable usage should use sanitized name"
786 );
787 assert!(
788 script.contains("billing_subscriptions_v1_errors.add"),
789 "Variable usage should use sanitized name"
790 );
791 }
792
793 #[test]
800 fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
801 use crate::spec_parser::ApiOperation;
802 use openapiv3::Operation;
803
804 let operation = ApiOperation {
805 method: "get".to_string(),
806 path: "/users".to_string(),
807 operation: Operation::default(),
808 operation_id: Some("listUsers".to_string()),
809 };
810 let template = RequestTemplate {
811 operation,
812 path_params: HashMap::new(),
813 query_params: HashMap::new(),
814 headers: HashMap::new(),
815 body: None,
816 };
817
818 let config = K6Config {
819 target_url: "https://api.example.com".to_string(),
820 base_path: None,
821 scenario: LoadScenario::RampUp,
822 duration_secs: 600,
823 max_vus: 100,
824 threshold_percentile: "p(95)".to_string(),
825 threshold_ms: 500,
826 max_error_rate: 0.05,
827 auth_header: None,
828 custom_headers: HashMap::new(),
829 skip_tls_verify: false,
830 security_testing_enabled: false,
831 chunked_request_bodies: false,
832 target_rps: Some(100),
833 no_keep_alive: false,
834 };
835
836 let generator = K6ScriptGenerator::new(config, vec![template]);
837 let script = generator.generate().expect("Should generate script");
838
839 assert!(
840 script.contains("constant-arrival-rate"),
841 "with --rps set, executor must switch to constant-arrival-rate"
842 );
843 assert!(
844 script.contains("rate: 100,"),
845 "constant-arrival-rate must use the configured --rps as `rate`"
846 );
847 assert!(
848 script.contains("duration: '600s'"),
849 "duration must come from --duration, not the ramp-down stage; got:\n{}",
850 script
851 );
852 assert!(
853 script.contains("preAllocatedVUs: 100,"),
854 "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
855 script
856 );
857 assert!(
858 script.contains("maxVUs: 100,"),
859 "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
860 script
861 );
862 for (idx, line) in script.lines().enumerate() {
866 let trimmed = line.trim_start();
867 if trimmed.starts_with("//") || trimmed.starts_with("/*") {
868 continue;
869 }
870 assert!(
871 !trimmed.starts_with("preAllocatedVUs: 0"),
872 "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
873 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
874 idx + 1,
875 line,
876 );
877 }
878 }
879
880 #[test]
883 fn test_cps_sets_no_connection_reuse() {
884 use crate::spec_parser::ApiOperation;
885 use openapiv3::Operation;
886
887 let operation = ApiOperation {
888 method: "get".to_string(),
889 path: "/u".to_string(),
890 operation: Operation::default(),
891 operation_id: Some("u".to_string()),
892 };
893 let template = RequestTemplate {
894 operation,
895 path_params: HashMap::new(),
896 query_params: HashMap::new(),
897 headers: HashMap::new(),
898 body: None,
899 };
900 let config = K6Config {
901 target_url: "https://api.example.com".to_string(),
902 base_path: None,
903 scenario: LoadScenario::Constant,
904 duration_secs: 30,
905 max_vus: 5,
906 threshold_percentile: "p(95)".to_string(),
907 threshold_ms: 500,
908 max_error_rate: 0.05,
909 auth_header: None,
910 custom_headers: HashMap::new(),
911 skip_tls_verify: false,
912 security_testing_enabled: false,
913 chunked_request_bodies: false,
914 target_rps: None,
915 no_keep_alive: true,
916 };
917 let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
918 assert!(
919 script.contains("noConnectionReuse: true"),
920 "--cps must set noConnectionReuse: true on the k6 options block"
921 );
922 assert!(
923 script.contains("Total Connections:"),
924 "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
925 );
926 assert!(
927 script.contains("Connection Rate:"),
928 "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
929 );
930 }
931
932 #[test]
933 fn test_validate_script_valid() {
934 let valid_script = r#"
935import http from 'k6/http';
936import { check, sleep } from 'k6';
937import { Rate, Trend } from 'k6/metrics';
938
939const test_latency = new Trend('test_latency');
940const test_errors = new Rate('test_errors');
941
942export default function() {
943 const res = http.get('https://example.com');
944 test_latency.add(res.timings.duration);
945 test_errors.add(res.status !== 200);
946}
947"#;
948
949 let errors = K6ScriptGenerator::validate_script(valid_script);
950 assert!(errors.is_empty(), "Valid script should have no validation errors");
951 }
952
953 #[test]
954 fn test_validate_script_invalid_metric_name() {
955 let invalid_script = r#"
956import http from 'k6/http';
957import { check, sleep } from 'k6';
958import { Rate, Trend } from 'k6/metrics';
959
960const test_latency = new Trend('test.latency');
961const test_errors = new Rate('test_errors');
962
963export default function() {
964 const res = http.get('https://example.com');
965 test_latency.add(res.timings.duration);
966}
967"#;
968
969 let errors = K6ScriptGenerator::validate_script(invalid_script);
970 assert!(
971 !errors.is_empty(),
972 "Script with invalid metric name should have validation errors"
973 );
974 assert!(
975 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
976 "Should detect invalid metric name with dot"
977 );
978 }
979
980 #[test]
981 fn test_validate_script_missing_imports() {
982 let invalid_script = r#"
983const test_latency = new Trend('test_latency');
984export default function() {}
985"#;
986
987 let errors = K6ScriptGenerator::validate_script(invalid_script);
988 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
989 }
990
991 #[test]
992 fn test_validate_script_metric_name_validation() {
993 let valid_script = r#"
996import http from 'k6/http';
997import { check, sleep } from 'k6';
998import { Rate, Trend } from 'k6/metrics';
999const test_latency = new Trend('test_latency');
1000const test_errors = new Rate('test_errors');
1001export default function() {}
1002"#;
1003 let errors = K6ScriptGenerator::validate_script(valid_script);
1004 assert!(errors.is_empty(), "Valid metric names should pass validation");
1005
1006 let invalid_cases = vec![
1008 ("test.latency", "dot in metric name"),
1009 ("123test", "starts with number"),
1010 ("test-latency", "hyphen in metric name"),
1011 ("test@latency", "special character"),
1012 ];
1013
1014 for (invalid_name, description) in invalid_cases {
1015 let script = format!(
1016 r#"
1017import http from 'k6/http';
1018import {{ check, sleep }} from 'k6';
1019import {{ Rate, Trend }} from 'k6/metrics';
1020const test_latency = new Trend('{}');
1021export default function() {{}}
1022"#,
1023 invalid_name
1024 );
1025 let errors = K6ScriptGenerator::validate_script(&script);
1026 assert!(
1027 !errors.is_empty(),
1028 "Metric name '{}' ({}) should fail validation",
1029 invalid_name,
1030 description
1031 );
1032 }
1033 }
1034
1035 #[test]
1036 fn test_skip_tls_verify_with_body() {
1037 use crate::spec_parser::ApiOperation;
1038 use openapiv3::Operation;
1039 use serde_json::json;
1040
1041 let operation = ApiOperation {
1043 method: "post".to_string(),
1044 path: "/api/users".to_string(),
1045 operation: Operation::default(),
1046 operation_id: Some("createUser".to_string()),
1047 };
1048
1049 let template = RequestTemplate {
1050 operation,
1051 path_params: HashMap::new(),
1052 query_params: HashMap::new(),
1053 headers: HashMap::new(),
1054 body: Some(json!({"name": "test"})),
1055 };
1056
1057 let config = K6Config {
1058 target_url: "https://api.example.com".to_string(),
1059 base_path: None,
1060 scenario: LoadScenario::Constant,
1061 duration_secs: 30,
1062 max_vus: 5,
1063 threshold_percentile: "p(95)".to_string(),
1064 threshold_ms: 500,
1065 max_error_rate: 0.05,
1066 auth_header: None,
1067 custom_headers: HashMap::new(),
1068 skip_tls_verify: true,
1069 security_testing_enabled: false,
1070 chunked_request_bodies: false,
1071 target_rps: None,
1072 no_keep_alive: false,
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("insecureSkipTLSVerify: true"),
1081 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_skip_tls_verify_without_body() {
1087 use crate::spec_parser::ApiOperation;
1088 use openapiv3::Operation;
1089
1090 let operation = ApiOperation {
1092 method: "get".to_string(),
1093 path: "/api/users".to_string(),
1094 operation: Operation::default(),
1095 operation_id: Some("getUsers".to_string()),
1096 };
1097
1098 let template = RequestTemplate {
1099 operation,
1100 path_params: HashMap::new(),
1101 query_params: HashMap::new(),
1102 headers: HashMap::new(),
1103 body: None,
1104 };
1105
1106 let config = K6Config {
1107 target_url: "https://api.example.com".to_string(),
1108 base_path: None,
1109 scenario: LoadScenario::Constant,
1110 duration_secs: 30,
1111 max_vus: 5,
1112 threshold_percentile: "p(95)".to_string(),
1113 threshold_ms: 500,
1114 max_error_rate: 0.05,
1115 auth_header: None,
1116 custom_headers: HashMap::new(),
1117 skip_tls_verify: true,
1118 security_testing_enabled: false,
1119 chunked_request_bodies: false,
1120 target_rps: None,
1121 no_keep_alive: false,
1122 };
1123
1124 let generator = K6ScriptGenerator::new(config, vec![template]);
1125 let script = generator.generate().expect("Should generate script");
1126
1127 assert!(
1129 script.contains("insecureSkipTLSVerify: true"),
1130 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_no_skip_tls_verify() {
1136 use crate::spec_parser::ApiOperation;
1137 use openapiv3::Operation;
1138
1139 let operation = ApiOperation {
1141 method: "get".to_string(),
1142 path: "/api/users".to_string(),
1143 operation: Operation::default(),
1144 operation_id: Some("getUsers".to_string()),
1145 };
1146
1147 let template = RequestTemplate {
1148 operation,
1149 path_params: HashMap::new(),
1150 query_params: HashMap::new(),
1151 headers: HashMap::new(),
1152 body: None,
1153 };
1154
1155 let config = K6Config {
1156 target_url: "https://api.example.com".to_string(),
1157 base_path: None,
1158 scenario: LoadScenario::Constant,
1159 duration_secs: 30,
1160 max_vus: 5,
1161 threshold_percentile: "p(95)".to_string(),
1162 threshold_ms: 500,
1163 max_error_rate: 0.05,
1164 auth_header: None,
1165 custom_headers: HashMap::new(),
1166 skip_tls_verify: false,
1167 security_testing_enabled: false,
1168 chunked_request_bodies: false,
1169 target_rps: None,
1170 no_keep_alive: false,
1171 };
1172
1173 let generator = K6ScriptGenerator::new(config, vec![template]);
1174 let script = generator.generate().expect("Should generate script");
1175
1176 assert!(
1178 !script.contains("insecureSkipTLSVerify"),
1179 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_skip_tls_verify_multiple_operations() {
1185 use crate::spec_parser::ApiOperation;
1186 use openapiv3::Operation;
1187 use serde_json::json;
1188
1189 let operation1 = ApiOperation {
1191 method: "get".to_string(),
1192 path: "/api/users".to_string(),
1193 operation: Operation::default(),
1194 operation_id: Some("getUsers".to_string()),
1195 };
1196
1197 let operation2 = ApiOperation {
1198 method: "post".to_string(),
1199 path: "/api/users".to_string(),
1200 operation: Operation::default(),
1201 operation_id: Some("createUser".to_string()),
1202 };
1203
1204 let template1 = RequestTemplate {
1205 operation: operation1,
1206 path_params: HashMap::new(),
1207 query_params: HashMap::new(),
1208 headers: HashMap::new(),
1209 body: None,
1210 };
1211
1212 let template2 = RequestTemplate {
1213 operation: operation2,
1214 path_params: HashMap::new(),
1215 query_params: HashMap::new(),
1216 headers: HashMap::new(),
1217 body: Some(json!({"name": "test"})),
1218 };
1219
1220 let config = K6Config {
1221 target_url: "https://api.example.com".to_string(),
1222 base_path: None,
1223 scenario: LoadScenario::Constant,
1224 duration_secs: 30,
1225 max_vus: 5,
1226 threshold_percentile: "p(95)".to_string(),
1227 threshold_ms: 500,
1228 max_error_rate: 0.05,
1229 auth_header: None,
1230 custom_headers: HashMap::new(),
1231 skip_tls_verify: true,
1232 security_testing_enabled: false,
1233 chunked_request_bodies: false,
1234 target_rps: None,
1235 no_keep_alive: false,
1236 };
1237
1238 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
1239 let script = generator.generate().expect("Should generate script");
1240
1241 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
1244 assert_eq!(
1245 skip_count, 1,
1246 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
1247 );
1248
1249 let options_start = script.find("export const options = {").expect("Should have options");
1251 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
1252 let options_prefix = &script[options_start..scenarios_start];
1253 assert!(
1254 options_prefix.contains("insecureSkipTLSVerify: true"),
1255 "insecureSkipTLSVerify should be in global options block"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_dynamic_params_in_body() {
1261 use crate::spec_parser::ApiOperation;
1262 use openapiv3::Operation;
1263 use serde_json::json;
1264
1265 let operation = ApiOperation {
1267 method: "post".to_string(),
1268 path: "/api/resources".to_string(),
1269 operation: Operation::default(),
1270 operation_id: Some("createResource".to_string()),
1271 };
1272
1273 let template = RequestTemplate {
1274 operation,
1275 path_params: HashMap::new(),
1276 query_params: HashMap::new(),
1277 headers: HashMap::new(),
1278 body: Some(json!({
1279 "name": "load-test-${__VU}",
1280 "iteration": "${__ITER}"
1281 })),
1282 };
1283
1284 let config = K6Config {
1285 target_url: "https://api.example.com".to_string(),
1286 base_path: None,
1287 scenario: LoadScenario::Constant,
1288 duration_secs: 30,
1289 max_vus: 5,
1290 threshold_percentile: "p(95)".to_string(),
1291 threshold_ms: 500,
1292 max_error_rate: 0.05,
1293 auth_header: None,
1294 custom_headers: HashMap::new(),
1295 skip_tls_verify: false,
1296 security_testing_enabled: false,
1297 chunked_request_bodies: false,
1298 target_rps: None,
1299 no_keep_alive: false,
1300 };
1301
1302 let generator = K6ScriptGenerator::new(config, vec![template]);
1303 let script = generator.generate().expect("Should generate script");
1304
1305 assert!(
1307 script.contains("Dynamic body with runtime placeholders"),
1308 "Script should contain comment about dynamic body"
1309 );
1310
1311 assert!(
1313 script.contains("__VU"),
1314 "Script should contain __VU reference for dynamic VU-based values"
1315 );
1316
1317 assert!(
1319 script.contains("__ITER"),
1320 "Script should contain __ITER reference for dynamic iteration values"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_dynamic_params_with_uuid() {
1326 use crate::spec_parser::ApiOperation;
1327 use openapiv3::Operation;
1328 use serde_json::json;
1329
1330 let operation = ApiOperation {
1332 method: "post".to_string(),
1333 path: "/api/resources".to_string(),
1334 operation: Operation::default(),
1335 operation_id: Some("createResource".to_string()),
1336 };
1337
1338 let template = RequestTemplate {
1339 operation,
1340 path_params: HashMap::new(),
1341 query_params: HashMap::new(),
1342 headers: HashMap::new(),
1343 body: Some(json!({
1344 "id": "${__UUID}"
1345 })),
1346 };
1347
1348 let config = K6Config {
1349 target_url: "https://api.example.com".to_string(),
1350 base_path: None,
1351 scenario: LoadScenario::Constant,
1352 duration_secs: 30,
1353 max_vus: 5,
1354 threshold_percentile: "p(95)".to_string(),
1355 threshold_ms: 500,
1356 max_error_rate: 0.05,
1357 auth_header: None,
1358 custom_headers: HashMap::new(),
1359 skip_tls_verify: false,
1360 security_testing_enabled: false,
1361 chunked_request_bodies: false,
1362 target_rps: None,
1363 no_keep_alive: false,
1364 };
1365
1366 let generator = K6ScriptGenerator::new(config, vec![template]);
1367 let script = generator.generate().expect("Should generate script");
1368
1369 assert!(
1372 !script.contains("k6/experimental/webcrypto"),
1373 "Script should NOT include deprecated k6/experimental/webcrypto import"
1374 );
1375
1376 assert!(
1378 script.contains("crypto.randomUUID()"),
1379 "Script should contain crypto.randomUUID() for UUID placeholder"
1380 );
1381 }
1382
1383 #[test]
1384 fn test_dynamic_params_with_counter() {
1385 use crate::spec_parser::ApiOperation;
1386 use openapiv3::Operation;
1387 use serde_json::json;
1388
1389 let operation = ApiOperation {
1391 method: "post".to_string(),
1392 path: "/api/resources".to_string(),
1393 operation: Operation::default(),
1394 operation_id: Some("createResource".to_string()),
1395 };
1396
1397 let template = RequestTemplate {
1398 operation,
1399 path_params: HashMap::new(),
1400 query_params: HashMap::new(),
1401 headers: HashMap::new(),
1402 body: Some(json!({
1403 "sequence": "${__COUNTER}"
1404 })),
1405 };
1406
1407 let config = K6Config {
1408 target_url: "https://api.example.com".to_string(),
1409 base_path: None,
1410 scenario: LoadScenario::Constant,
1411 duration_secs: 30,
1412 max_vus: 5,
1413 threshold_percentile: "p(95)".to_string(),
1414 threshold_ms: 500,
1415 max_error_rate: 0.05,
1416 auth_header: None,
1417 custom_headers: HashMap::new(),
1418 skip_tls_verify: false,
1419 security_testing_enabled: false,
1420 chunked_request_bodies: false,
1421 target_rps: None,
1422 no_keep_alive: false,
1423 };
1424
1425 let generator = K6ScriptGenerator::new(config, vec![template]);
1426 let script = generator.generate().expect("Should generate script");
1427
1428 assert!(
1430 script.contains("let globalCounter = 0"),
1431 "Script should include globalCounter initialization when COUNTER placeholder is used"
1432 );
1433
1434 assert!(
1436 script.contains("globalCounter++"),
1437 "Script should contain globalCounter++ for COUNTER placeholder"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_static_body_no_dynamic_marker() {
1443 use crate::spec_parser::ApiOperation;
1444 use openapiv3::Operation;
1445 use serde_json::json;
1446
1447 let operation = ApiOperation {
1449 method: "post".to_string(),
1450 path: "/api/resources".to_string(),
1451 operation: Operation::default(),
1452 operation_id: Some("createResource".to_string()),
1453 };
1454
1455 let template = RequestTemplate {
1456 operation,
1457 path_params: HashMap::new(),
1458 query_params: HashMap::new(),
1459 headers: HashMap::new(),
1460 body: Some(json!({
1461 "name": "static-value",
1462 "count": 42
1463 })),
1464 };
1465
1466 let config = K6Config {
1467 target_url: "https://api.example.com".to_string(),
1468 base_path: None,
1469 scenario: LoadScenario::Constant,
1470 duration_secs: 30,
1471 max_vus: 5,
1472 threshold_percentile: "p(95)".to_string(),
1473 threshold_ms: 500,
1474 max_error_rate: 0.05,
1475 auth_header: None,
1476 custom_headers: HashMap::new(),
1477 skip_tls_verify: false,
1478 security_testing_enabled: false,
1479 chunked_request_bodies: false,
1480 target_rps: None,
1481 no_keep_alive: false,
1482 };
1483
1484 let generator = K6ScriptGenerator::new(config, vec![template]);
1485 let script = generator.generate().expect("Should generate script");
1486
1487 assert!(
1489 !script.contains("Dynamic body with runtime placeholders"),
1490 "Script should NOT contain dynamic body comment for static body"
1491 );
1492
1493 assert!(
1495 !script.contains("webcrypto"),
1496 "Script should NOT include webcrypto import for static body"
1497 );
1498
1499 assert!(
1501 !script.contains("let globalCounter"),
1502 "Script should NOT include globalCounter for static body"
1503 );
1504 }
1505
1506 #[test]
1507 fn test_security_testing_enabled_generates_calling_code() {
1508 use crate::spec_parser::ApiOperation;
1509 use openapiv3::Operation;
1510 use serde_json::json;
1511
1512 let operation = ApiOperation {
1513 method: "post".to_string(),
1514 path: "/api/users".to_string(),
1515 operation: Operation::default(),
1516 operation_id: Some("createUser".to_string()),
1517 };
1518
1519 let template = RequestTemplate {
1520 operation,
1521 path_params: HashMap::new(),
1522 query_params: HashMap::new(),
1523 headers: HashMap::new(),
1524 body: Some(json!({"name": "test"})),
1525 };
1526
1527 let config = K6Config {
1528 target_url: "https://api.example.com".to_string(),
1529 base_path: None,
1530 scenario: LoadScenario::Constant,
1531 duration_secs: 30,
1532 max_vus: 5,
1533 threshold_percentile: "p(95)".to_string(),
1534 threshold_ms: 500,
1535 max_error_rate: 0.05,
1536 auth_header: None,
1537 custom_headers: HashMap::new(),
1538 skip_tls_verify: false,
1539 security_testing_enabled: true,
1540 chunked_request_bodies: false,
1541 target_rps: None,
1542 no_keep_alive: false,
1543 };
1544
1545 let generator = K6ScriptGenerator::new(config, vec![template]);
1546 let script = generator.generate().expect("Should generate script");
1547
1548 assert!(
1550 script.contains("getNextSecurityPayload"),
1551 "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
1552 );
1553 assert!(
1554 script.contains("applySecurityPayload"),
1555 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
1556 );
1557 assert!(
1558 script.contains("secPayloadGroup"),
1559 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
1560 );
1561 assert!(
1562 script.contains("secBodyPayload"),
1563 "Script should contain secBodyPayload variable when security_testing_enabled is true"
1564 );
1565 assert!(
1567 script.contains("hasSecCookie"),
1568 "Script should track hasSecCookie for CookieJar conflict avoidance"
1569 );
1570 assert!(
1571 script.contains("secRequestOpts"),
1572 "Script should use secRequestOpts to conditionally skip CookieJar"
1573 );
1574 assert!(
1576 script.contains("const requestHeaders = { ..."),
1577 "Script should spread headers into mutable copy for security payload injection"
1578 );
1579 assert!(
1581 script.contains("secPayload.injectAsPath"),
1582 "Script should check injectAsPath for path-based URI injection"
1583 );
1584 assert!(
1586 script.contains("secBodyPayload.formBody"),
1587 "Script should check formBody for form-encoded body delivery"
1588 );
1589 assert!(
1590 script.contains("application/x-www-form-urlencoded"),
1591 "Script should set Content-Type for form-encoded body"
1592 );
1593 let op_comment_pos =
1595 script.find("// Operation 0:").expect("Should have Operation 0 comment");
1596 let sec_payload_pos = script
1597 .find("const secPayloadGroup = typeof getNextSecurityPayload")
1598 .expect("Should have secPayloadGroup assignment");
1599 assert!(
1600 sec_payload_pos > op_comment_pos,
1601 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_security_testing_disabled_no_calling_code() {
1607 use crate::spec_parser::ApiOperation;
1608 use openapiv3::Operation;
1609 use serde_json::json;
1610
1611 let operation = ApiOperation {
1612 method: "post".to_string(),
1613 path: "/api/users".to_string(),
1614 operation: Operation::default(),
1615 operation_id: Some("createUser".to_string()),
1616 };
1617
1618 let template = RequestTemplate {
1619 operation,
1620 path_params: HashMap::new(),
1621 query_params: HashMap::new(),
1622 headers: HashMap::new(),
1623 body: Some(json!({"name": "test"})),
1624 };
1625
1626 let config = K6Config {
1627 target_url: "https://api.example.com".to_string(),
1628 base_path: None,
1629 scenario: LoadScenario::Constant,
1630 duration_secs: 30,
1631 max_vus: 5,
1632 threshold_percentile: "p(95)".to_string(),
1633 threshold_ms: 500,
1634 max_error_rate: 0.05,
1635 auth_header: None,
1636 custom_headers: HashMap::new(),
1637 skip_tls_verify: false,
1638 security_testing_enabled: false,
1639 chunked_request_bodies: false,
1640 target_rps: None,
1641 no_keep_alive: false,
1642 };
1643
1644 let generator = K6ScriptGenerator::new(config, vec![template]);
1645 let script = generator.generate().expect("Should generate script");
1646
1647 assert!(
1649 !script.contains("getNextSecurityPayload"),
1650 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1651 );
1652 assert!(
1653 !script.contains("applySecurityPayload"),
1654 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1655 );
1656 assert!(
1657 !script.contains("secPayloadGroup"),
1658 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
1659 );
1660 assert!(
1661 !script.contains("secBodyPayload"),
1662 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
1663 );
1664 assert!(
1665 !script.contains("hasSecCookie"),
1666 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
1667 );
1668 assert!(
1669 !script.contains("secRequestOpts"),
1670 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
1671 );
1672 assert!(
1673 !script.contains("injectAsPath"),
1674 "Script should NOT contain injectAsPath when security_testing_enabled is false"
1675 );
1676 assert!(
1677 !script.contains("formBody"),
1678 "Script should NOT contain formBody when security_testing_enabled is false"
1679 );
1680 }
1681
1682 #[test]
1686 fn test_security_e2e_definitions_and_calls_both_present() {
1687 use crate::security_payloads::{
1688 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1689 };
1690 use crate::spec_parser::ApiOperation;
1691 use openapiv3::Operation;
1692 use serde_json::json;
1693
1694 let operation = ApiOperation {
1696 method: "post".to_string(),
1697 path: "/api/users".to_string(),
1698 operation: Operation::default(),
1699 operation_id: Some("createUser".to_string()),
1700 };
1701
1702 let template = RequestTemplate {
1703 operation,
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: false,
1722 security_testing_enabled: true,
1723 chunked_request_bodies: false,
1724 target_rps: None,
1725 no_keep_alive: false,
1726 };
1727
1728 let generator = K6ScriptGenerator::new(config, vec![template]);
1729 let mut script = generator.generate().expect("Should generate base script");
1730
1731 let security_config = SecurityTestConfig::default().enable();
1733 let payloads = SecurityPayloads::get_payloads(&security_config);
1734 assert!(!payloads.is_empty(), "Should have built-in payloads");
1735
1736 let mut additional_code = String::new();
1737 additional_code
1738 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1739 additional_code.push('\n');
1740 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1741 additional_code.push('\n');
1742
1743 if let Some(pos) = script.find("export const options") {
1745 script.insert_str(
1746 pos,
1747 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1748 );
1749 }
1750
1751 assert!(
1754 script.contains("function getNextSecurityPayload()"),
1755 "Final script must contain getNextSecurityPayload function DEFINITION"
1756 );
1757 assert!(
1758 script.contains("function applySecurityPayload("),
1759 "Final script must contain applySecurityPayload function DEFINITION"
1760 );
1761 assert!(
1762 script.contains("securityPayloads"),
1763 "Final script must contain securityPayloads array"
1764 );
1765
1766 assert!(
1768 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
1769 "Final script must contain secPayloadGroup assignment (template calling code)"
1770 );
1771 assert!(
1772 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1773 "Final script must contain applySecurityPayload CALL with secBodyPayload"
1774 );
1775 assert!(
1776 script.contains("const requestHeaders = { ..."),
1777 "Final script must spread headers for security payload header injection"
1778 );
1779 assert!(
1780 script.contains("for (const secPayload of secPayloadGroup)"),
1781 "Final script must loop over secPayloadGroup"
1782 );
1783 assert!(
1784 script.contains("secPayload.injectAsPath"),
1785 "Final script must check injectAsPath for path-based URI injection"
1786 );
1787 assert!(
1788 script.contains("secBodyPayload.formBody"),
1789 "Final script must check formBody for form-encoded body delivery"
1790 );
1791
1792 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1794 let call_pos =
1795 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
1796 let options_pos = script.find("export const options").unwrap();
1797 let default_fn_pos = script.find("export default function").unwrap();
1798
1799 assert!(
1800 def_pos < options_pos,
1801 "Function definitions must appear before export const options"
1802 );
1803 assert!(
1804 call_pos > default_fn_pos,
1805 "Calling code must appear inside export default function"
1806 );
1807 }
1808
1809 #[test]
1811 fn test_security_uri_injection_for_get_requests() {
1812 use crate::spec_parser::ApiOperation;
1813 use openapiv3::Operation;
1814
1815 let operation = ApiOperation {
1816 method: "get".to_string(),
1817 path: "/api/users".to_string(),
1818 operation: Operation::default(),
1819 operation_id: Some("listUsers".to_string()),
1820 };
1821
1822 let template = RequestTemplate {
1823 operation,
1824 path_params: HashMap::new(),
1825 query_params: HashMap::new(),
1826 headers: HashMap::new(),
1827 body: None,
1828 };
1829
1830 let config = K6Config {
1831 target_url: "https://api.example.com".to_string(),
1832 base_path: None,
1833 scenario: LoadScenario::Constant,
1834 duration_secs: 30,
1835 max_vus: 5,
1836 threshold_percentile: "p(95)".to_string(),
1837 threshold_ms: 500,
1838 max_error_rate: 0.05,
1839 auth_header: None,
1840 custom_headers: HashMap::new(),
1841 skip_tls_verify: false,
1842 security_testing_enabled: true,
1843 chunked_request_bodies: false,
1844 target_rps: None,
1845 no_keep_alive: false,
1846 };
1847
1848 let generator = K6ScriptGenerator::new(config, vec![template]);
1849 let script = generator.generate().expect("Should generate script");
1850
1851 assert!(
1853 script.contains("requestUrl"),
1854 "Script should build requestUrl variable for URI payload injection"
1855 );
1856 assert!(
1857 script.contains("secPayload.location === 'uri'"),
1858 "Script should check for URI-location payloads"
1859 );
1860 assert!(
1862 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
1863 "Script should URL-encode security payload in query string for valid HTTP"
1864 );
1865 assert!(
1867 script.contains("secPayload.injectAsPath"),
1868 "Script should check injectAsPath for path-based URI injection"
1869 );
1870 assert!(
1871 script.contains("encodeURI(secPayload.payload)"),
1872 "Script should use encodeURI for path-based injection"
1873 );
1874 assert!(
1876 script.contains("http.get(requestUrl,"),
1877 "GET request should use requestUrl (with URI injection) instead of inline URL"
1878 );
1879 }
1880
1881 #[test]
1883 fn test_security_uri_injection_for_post_requests() {
1884 use crate::spec_parser::ApiOperation;
1885 use openapiv3::Operation;
1886 use serde_json::json;
1887
1888 let operation = ApiOperation {
1889 method: "post".to_string(),
1890 path: "/api/users".to_string(),
1891 operation: Operation::default(),
1892 operation_id: Some("createUser".to_string()),
1893 };
1894
1895 let template = RequestTemplate {
1896 operation,
1897 path_params: HashMap::new(),
1898 query_params: HashMap::new(),
1899 headers: HashMap::new(),
1900 body: Some(json!({"name": "test"})),
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: true,
1916 chunked_request_bodies: false,
1917 target_rps: None,
1918 no_keep_alive: false,
1919 };
1920
1921 let generator = K6ScriptGenerator::new(config, vec![template]);
1922 let script = generator.generate().expect("Should generate script");
1923
1924 assert!(
1926 script.contains("requestUrl"),
1927 "POST script should build requestUrl for URI payload injection"
1928 );
1929 assert!(
1930 script.contains("secPayload.location === 'uri'"),
1931 "POST script should check for URI-location payloads"
1932 );
1933 assert!(
1934 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1935 "POST script should apply security body payload to request body"
1936 );
1937 assert!(
1939 script.contains("http.post(requestUrl,"),
1940 "POST request should use requestUrl (with URI injection) instead of inline URL"
1941 );
1942 }
1943
1944 #[test]
1946 fn test_no_uri_injection_when_security_disabled() {
1947 use crate::spec_parser::ApiOperation;
1948 use openapiv3::Operation;
1949
1950 let operation = ApiOperation {
1951 method: "get".to_string(),
1952 path: "/api/users".to_string(),
1953 operation: Operation::default(),
1954 operation_id: Some("listUsers".to_string()),
1955 };
1956
1957 let template = RequestTemplate {
1958 operation,
1959 path_params: HashMap::new(),
1960 query_params: HashMap::new(),
1961 headers: HashMap::new(),
1962 body: None,
1963 };
1964
1965 let config = K6Config {
1966 target_url: "https://api.example.com".to_string(),
1967 base_path: None,
1968 scenario: LoadScenario::Constant,
1969 duration_secs: 30,
1970 max_vus: 5,
1971 threshold_percentile: "p(95)".to_string(),
1972 threshold_ms: 500,
1973 max_error_rate: 0.05,
1974 auth_header: None,
1975 custom_headers: HashMap::new(),
1976 skip_tls_verify: false,
1977 security_testing_enabled: false,
1978 chunked_request_bodies: false,
1979 target_rps: None,
1980 no_keep_alive: false,
1981 };
1982
1983 let generator = K6ScriptGenerator::new(config, vec![template]);
1984 let script = generator.generate().expect("Should generate script");
1985
1986 assert!(
1988 !script.contains("requestUrl"),
1989 "Script should NOT have requestUrl when security is disabled"
1990 );
1991 assert!(
1992 !script.contains("secPayloadGroup"),
1993 "Script should NOT have secPayloadGroup when security is disabled"
1994 );
1995 assert!(
1996 !script.contains("secBodyPayload"),
1997 "Script should NOT have secBodyPayload when security is disabled"
1998 );
1999 }
2000
2001 #[test]
2003 fn test_uses_per_request_cookie_jar() {
2004 use crate::spec_parser::ApiOperation;
2005 use openapiv3::Operation;
2006
2007 let operation = ApiOperation {
2008 method: "get".to_string(),
2009 path: "/api/users".to_string(),
2010 operation: Operation::default(),
2011 operation_id: Some("listUsers".to_string()),
2012 };
2013
2014 let template = RequestTemplate {
2015 operation,
2016 path_params: HashMap::new(),
2017 query_params: HashMap::new(),
2018 headers: HashMap::new(),
2019 body: None,
2020 };
2021
2022 let config = K6Config {
2023 target_url: "https://api.example.com".to_string(),
2024 base_path: None,
2025 scenario: LoadScenario::Constant,
2026 duration_secs: 30,
2027 max_vus: 5,
2028 threshold_percentile: "p(95)".to_string(),
2029 threshold_ms: 500,
2030 max_error_rate: 0.05,
2031 auth_header: None,
2032 custom_headers: HashMap::new(),
2033 skip_tls_verify: false,
2034 security_testing_enabled: false,
2035 chunked_request_bodies: false,
2036 target_rps: None,
2037 no_keep_alive: false,
2038 };
2039
2040 let generator = K6ScriptGenerator::new(config, vec![template]);
2041 let script = generator.generate().expect("Should generate script");
2042
2043 assert!(
2045 script.contains("jar: new http.CookieJar()"),
2046 "Script should create fresh CookieJar per request"
2047 );
2048 assert!(
2049 !script.contains("jar: null"),
2050 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2051 );
2052 assert!(
2053 !script.contains("EMPTY_JAR"),
2054 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2055 );
2056 }
2057}