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}
35
36#[derive(Debug, Clone, Serialize)]
38pub struct K6CrudFlowTemplateData {
39 pub base_url: String,
40 pub flows: Vec<Value>,
41 pub extract_fields: Vec<String>,
42 pub duration_secs: u64,
43 pub max_vus: u32,
44 pub auth_header: Option<String>,
45 pub custom_headers: HashMap<String, String>,
46 pub skip_tls_verify: bool,
47 pub stages: Vec<K6StageData>,
48 pub threshold_percentile: String,
49 pub threshold_ms: u64,
50 pub max_error_rate: f64,
51 pub headers: String,
53 pub dynamic_imports: Vec<String>,
54 pub dynamic_globals: Vec<String>,
55 pub extracted_values_output_path: String,
56 pub error_injection_enabled: bool,
57 pub error_rate: f64,
58 pub error_types: Vec<String>,
59 pub security_testing_enabled: bool,
60 pub has_custom_headers: bool,
61}
62
63#[derive(Debug, Clone, Serialize)]
65pub struct K6StageData {
66 pub duration: String,
67 pub target: u32,
68}
69
70#[derive(Debug, Clone, Serialize)]
72pub struct K6OperationData {
73 pub index: usize,
74 pub name: String,
75 pub metric_name: String,
76 pub display_name: String,
77 pub method: String,
78 pub path: Value,
79 pub path_is_dynamic: bool,
80 pub headers: Value,
81 pub body: Option<Value>,
82 pub body_is_dynamic: bool,
83 pub has_body: bool,
84 pub is_get_or_head: bool,
85}
86
87pub struct K6Config {
89 pub target_url: String,
90 pub base_path: Option<String>,
93 pub scenario: LoadScenario,
94 pub duration_secs: u64,
95 pub max_vus: u32,
96 pub threshold_percentile: String,
97 pub threshold_ms: u64,
98 pub max_error_rate: f64,
99 pub auth_header: Option<String>,
100 pub custom_headers: HashMap<String, String>,
101 pub skip_tls_verify: bool,
102 pub security_testing_enabled: bool,
103}
104
105pub struct K6ScriptGenerator {
107 config: K6Config,
108 templates: Vec<RequestTemplate>,
109}
110
111impl K6ScriptGenerator {
112 pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
114 Self { config, templates }
115 }
116
117 pub fn generate(&self) -> Result<String> {
119 let handlebars = Handlebars::new();
120
121 let template = include_str!("templates/k6_script.hbs");
122
123 let data = self.build_template_data()?;
124
125 let value = serde_json::to_value(&data)
126 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
127
128 handlebars
129 .render_template(template, &value)
130 .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
131 }
132
133 pub fn sanitize_js_identifier(name: &str) -> String {
143 let mut result = String::new();
144 let mut chars = name.chars().peekable();
145
146 if let Some(&first) = chars.peek() {
148 if first.is_ascii_digit() {
149 result.push('_');
150 }
151 }
152
153 for ch in chars {
154 if ch.is_ascii_alphanumeric() || ch == '_' {
155 result.push(ch);
156 } else {
157 if !result.ends_with('_') {
160 result.push('_');
161 }
162 }
163 }
164
165 result = result.trim_end_matches('_').to_string();
167
168 if result.is_empty() {
170 result = "operation".to_string();
171 }
172
173 result
174 }
175
176 fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
178 let stages = self
179 .config
180 .scenario
181 .generate_stages(self.config.duration_secs, self.config.max_vus);
182
183 let base_path = self.config.base_path.as_deref().unwrap_or("");
185
186 let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
188
189 let operations = self
190 .templates
191 .iter()
192 .enumerate()
193 .map(|(idx, template)| {
194 let display_name = template.operation.display_name();
195 let sanitized_name = Self::sanitize_js_identifier(&display_name);
196 let metric_name = sanitized_name.clone();
199 let k6_method = match template.operation.method.to_lowercase().as_str() {
201 "delete" => "del".to_string(),
202 m => m.to_string(),
203 };
204 let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
207
208 let raw_path = template.generate_path();
211 let full_path = if base_path.is_empty() {
212 raw_path
213 } else {
214 format!("{}{}", base_path, raw_path)
215 };
216 let processed_path = DynamicParamProcessor::process_path(&full_path);
217 all_placeholders.extend(processed_path.placeholders.clone());
218
219 let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
221 let processed_body = DynamicParamProcessor::process_json_body(body);
222 all_placeholders.extend(processed_body.placeholders.clone());
223 (Some(processed_body.value), processed_body.is_dynamic)
224 } else {
225 (None, false)
226 };
227
228 let path_value = if processed_path.is_dynamic {
229 processed_path.value
230 } else {
231 full_path
232 };
233
234 K6OperationData {
235 index: idx,
236 name: sanitized_name,
237 metric_name,
238 display_name,
239 method: k6_method,
240 path: Value::String(path_value),
241 path_is_dynamic: processed_path.is_dynamic,
242 headers: Value::String(self.build_headers_json(template)),
243 body: body_value.map(Value::String),
244 body_is_dynamic,
245 has_body: template.body.is_some(),
246 is_get_or_head,
247 }
248 })
249 .collect::<Vec<_>>();
250
251 let required_imports: Vec<String> =
253 DynamicParamProcessor::get_required_imports(&all_placeholders)
254 .into_iter()
255 .map(String::from)
256 .collect();
257 let required_globals: Vec<String> =
258 DynamicParamProcessor::get_required_globals(&all_placeholders)
259 .into_iter()
260 .map(String::from)
261 .collect();
262 let has_dynamic_values = !all_placeholders.is_empty();
263
264 Ok(K6ScriptTemplateData {
265 base_url: self.config.target_url.clone(),
266 stages: stages
267 .iter()
268 .map(|s| K6StageData {
269 duration: s.duration.clone(),
270 target: s.target,
271 })
272 .collect(),
273 operations,
274 threshold_percentile: self.config.threshold_percentile.clone(),
275 threshold_ms: self.config.threshold_ms,
276 max_error_rate: self.config.max_error_rate,
277 scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
278 skip_tls_verify: self.config.skip_tls_verify,
279 has_dynamic_values,
280 dynamic_imports: required_imports,
281 dynamic_globals: required_globals,
282 security_testing_enabled: self.config.security_testing_enabled,
283 has_custom_headers: !self.config.custom_headers.is_empty(),
284 })
285 }
286
287 fn build_headers_json(&self, template: &RequestTemplate) -> String {
289 let mut headers = template.get_headers();
290
291 if let Some(auth) = &self.config.auth_header {
293 headers.insert("Authorization".to_string(), auth.clone());
294 }
295
296 for (key, value) in &self.config.custom_headers {
298 headers.insert(key.clone(), value.clone());
299 }
300
301 serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
303 }
304
305 pub fn validate_script(script: &str) -> Vec<String> {
314 let mut errors = Vec::new();
315
316 if !script.contains("import http from 'k6/http'") {
318 errors.push("Missing required import: 'k6/http'".to_string());
319 }
320 if !script.contains("import { check") && !script.contains("import {check") {
321 errors.push("Missing required import: 'check' from 'k6'".to_string());
322 }
323 if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
324 errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
325 }
326
327 let lines: Vec<&str> = script.lines().collect();
331 for (line_num, line) in lines.iter().enumerate() {
332 let trimmed = line.trim();
333
334 if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
336 if let Some(start) = trimmed.find('\'') {
339 if let Some(end) = trimmed[start + 1..].find('\'') {
340 let metric_name = &trimmed[start + 1..start + 1 + end];
341 if !Self::is_valid_k6_metric_name(metric_name) {
342 errors.push(format!(
343 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
344 line_num + 1,
345 metric_name
346 ));
347 }
348 }
349 } else if let Some(start) = trimmed.find('"') {
350 if let Some(end) = trimmed[start + 1..].find('"') {
351 let metric_name = &trimmed[start + 1..start + 1 + end];
352 if !Self::is_valid_k6_metric_name(metric_name) {
353 errors.push(format!(
354 "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
355 line_num + 1,
356 metric_name
357 ));
358 }
359 }
360 }
361 }
362
363 if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
365 if let Some(equals_pos) = trimmed.find('=') {
366 let var_decl = &trimmed[..equals_pos];
367 if var_decl.contains('.')
370 && !var_decl.contains("'")
371 && !var_decl.contains("\"")
372 && !var_decl.trim().starts_with("//")
373 {
374 errors.push(format!(
375 "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
376 line_num + 1,
377 var_decl.trim()
378 ));
379 }
380 }
381 }
382 }
383
384 errors
385 }
386
387 fn is_valid_k6_metric_name(name: &str) -> bool {
394 if name.is_empty() || name.len() > 128 {
395 return false;
396 }
397
398 let mut chars = name.chars();
399
400 if let Some(first) = chars.next() {
402 if !first.is_ascii_alphabetic() && first != '_' {
403 return false;
404 }
405 }
406
407 for ch in chars {
409 if !ch.is_ascii_alphanumeric() && ch != '_' {
410 return false;
411 }
412 }
413
414 true
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_k6_config_creation() {
424 let config = K6Config {
425 target_url: "https://api.example.com".to_string(),
426 base_path: None,
427 scenario: LoadScenario::RampUp,
428 duration_secs: 60,
429 max_vus: 10,
430 threshold_percentile: "p(95)".to_string(),
431 threshold_ms: 500,
432 max_error_rate: 0.05,
433 auth_header: None,
434 custom_headers: HashMap::new(),
435 skip_tls_verify: false,
436 security_testing_enabled: false,
437 };
438
439 assert_eq!(config.duration_secs, 60);
440 assert_eq!(config.max_vus, 10);
441 }
442
443 #[test]
444 fn test_script_generator_creation() {
445 let config = K6Config {
446 target_url: "https://api.example.com".to_string(),
447 base_path: None,
448 scenario: LoadScenario::Constant,
449 duration_secs: 30,
450 max_vus: 5,
451 threshold_percentile: "p(95)".to_string(),
452 threshold_ms: 500,
453 max_error_rate: 0.05,
454 auth_header: None,
455 custom_headers: HashMap::new(),
456 skip_tls_verify: false,
457 security_testing_enabled: false,
458 };
459
460 let templates = vec![];
461 let generator = K6ScriptGenerator::new(config, templates);
462
463 assert_eq!(generator.templates.len(), 0);
464 }
465
466 #[test]
467 fn test_sanitize_js_identifier() {
468 assert_eq!(
470 K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
471 "billing_subscriptions_v1"
472 );
473
474 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
476
477 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
479
480 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
482
483 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
485
486 assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
488
489 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
491
492 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
494 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
495 assert_eq!(
496 K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
497 "plans_update_pricing_schemes"
498 );
499 assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
500 }
501
502 #[test]
503 fn test_script_generation_with_dots_in_name() {
504 use crate::spec_parser::ApiOperation;
505 use openapiv3::Operation;
506
507 let operation = ApiOperation {
509 method: "get".to_string(),
510 path: "/billing/subscriptions".to_string(),
511 operation: Operation::default(),
512 operation_id: Some("billing.subscriptions.v1".to_string()),
513 };
514
515 let template = RequestTemplate {
516 operation,
517 path_params: HashMap::new(),
518 query_params: HashMap::new(),
519 headers: HashMap::new(),
520 body: None,
521 };
522
523 let config = K6Config {
524 target_url: "https://api.example.com".to_string(),
525 base_path: None,
526 scenario: LoadScenario::Constant,
527 duration_secs: 30,
528 max_vus: 5,
529 threshold_percentile: "p(95)".to_string(),
530 threshold_ms: 500,
531 max_error_rate: 0.05,
532 auth_header: None,
533 custom_headers: HashMap::new(),
534 skip_tls_verify: false,
535 security_testing_enabled: false,
536 };
537
538 let generator = K6ScriptGenerator::new(config, vec![template]);
539 let script = generator.generate().expect("Should generate script");
540
541 assert!(
543 script.contains("const billing_subscriptions_v1_latency"),
544 "Script should contain sanitized variable name for latency"
545 );
546 assert!(
547 script.contains("const billing_subscriptions_v1_errors"),
548 "Script should contain sanitized variable name for errors"
549 );
550
551 assert!(
554 !script.contains("const billing.subscriptions"),
555 "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
556 );
557
558 assert!(
561 script.contains("'billing_subscriptions_v1_latency'"),
562 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
563 );
564 assert!(
565 script.contains("'billing_subscriptions_v1_errors'"),
566 "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
567 );
568
569 assert!(
571 script.contains("billing.subscriptions.v1"),
572 "Script should contain original name in comments/strings for readability"
573 );
574
575 assert!(
577 script.contains("billing_subscriptions_v1_latency.add"),
578 "Variable usage should use sanitized name"
579 );
580 assert!(
581 script.contains("billing_subscriptions_v1_errors.add"),
582 "Variable usage should use sanitized name"
583 );
584 }
585
586 #[test]
587 fn test_validate_script_valid() {
588 let valid_script = r#"
589import http from 'k6/http';
590import { check, sleep } from 'k6';
591import { Rate, Trend } from 'k6/metrics';
592
593const test_latency = new Trend('test_latency');
594const test_errors = new Rate('test_errors');
595
596export default function() {
597 const res = http.get('https://example.com');
598 test_latency.add(res.timings.duration);
599 test_errors.add(res.status !== 200);
600}
601"#;
602
603 let errors = K6ScriptGenerator::validate_script(valid_script);
604 assert!(errors.is_empty(), "Valid script should have no validation errors");
605 }
606
607 #[test]
608 fn test_validate_script_invalid_metric_name() {
609 let invalid_script = r#"
610import http from 'k6/http';
611import { check, sleep } from 'k6';
612import { Rate, Trend } from 'k6/metrics';
613
614const test_latency = new Trend('test.latency');
615const test_errors = new Rate('test_errors');
616
617export default function() {
618 const res = http.get('https://example.com');
619 test_latency.add(res.timings.duration);
620}
621"#;
622
623 let errors = K6ScriptGenerator::validate_script(invalid_script);
624 assert!(
625 !errors.is_empty(),
626 "Script with invalid metric name should have validation errors"
627 );
628 assert!(
629 errors.iter().any(|e| e.contains("Invalid k6 metric name")),
630 "Should detect invalid metric name with dot"
631 );
632 }
633
634 #[test]
635 fn test_validate_script_missing_imports() {
636 let invalid_script = r#"
637const test_latency = new Trend('test_latency');
638export default function() {}
639"#;
640
641 let errors = K6ScriptGenerator::validate_script(invalid_script);
642 assert!(!errors.is_empty(), "Script missing imports should have validation errors");
643 }
644
645 #[test]
646 fn test_validate_script_metric_name_validation() {
647 let valid_script = r#"
650import http from 'k6/http';
651import { check, sleep } from 'k6';
652import { Rate, Trend } from 'k6/metrics';
653const test_latency = new Trend('test_latency');
654const test_errors = new Rate('test_errors');
655export default function() {}
656"#;
657 let errors = K6ScriptGenerator::validate_script(valid_script);
658 assert!(errors.is_empty(), "Valid metric names should pass validation");
659
660 let invalid_cases = vec![
662 ("test.latency", "dot in metric name"),
663 ("123test", "starts with number"),
664 ("test-latency", "hyphen in metric name"),
665 ("test@latency", "special character"),
666 ];
667
668 for (invalid_name, description) in invalid_cases {
669 let script = format!(
670 r#"
671import http from 'k6/http';
672import {{ check, sleep }} from 'k6';
673import {{ Rate, Trend }} from 'k6/metrics';
674const test_latency = new Trend('{}');
675export default function() {{}}
676"#,
677 invalid_name
678 );
679 let errors = K6ScriptGenerator::validate_script(&script);
680 assert!(
681 !errors.is_empty(),
682 "Metric name '{}' ({}) should fail validation",
683 invalid_name,
684 description
685 );
686 }
687 }
688
689 #[test]
690 fn test_skip_tls_verify_with_body() {
691 use crate::spec_parser::ApiOperation;
692 use openapiv3::Operation;
693 use serde_json::json;
694
695 let operation = ApiOperation {
697 method: "post".to_string(),
698 path: "/api/users".to_string(),
699 operation: Operation::default(),
700 operation_id: Some("createUser".to_string()),
701 };
702
703 let template = RequestTemplate {
704 operation,
705 path_params: HashMap::new(),
706 query_params: HashMap::new(),
707 headers: HashMap::new(),
708 body: Some(json!({"name": "test"})),
709 };
710
711 let config = K6Config {
712 target_url: "https://api.example.com".to_string(),
713 base_path: None,
714 scenario: LoadScenario::Constant,
715 duration_secs: 30,
716 max_vus: 5,
717 threshold_percentile: "p(95)".to_string(),
718 threshold_ms: 500,
719 max_error_rate: 0.05,
720 auth_header: None,
721 custom_headers: HashMap::new(),
722 skip_tls_verify: true,
723 security_testing_enabled: false,
724 };
725
726 let generator = K6ScriptGenerator::new(config, vec![template]);
727 let script = generator.generate().expect("Should generate script");
728
729 assert!(
731 script.contains("insecureSkipTLSVerify: true"),
732 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
733 );
734 }
735
736 #[test]
737 fn test_skip_tls_verify_without_body() {
738 use crate::spec_parser::ApiOperation;
739 use openapiv3::Operation;
740
741 let operation = ApiOperation {
743 method: "get".to_string(),
744 path: "/api/users".to_string(),
745 operation: Operation::default(),
746 operation_id: Some("getUsers".to_string()),
747 };
748
749 let template = RequestTemplate {
750 operation,
751 path_params: HashMap::new(),
752 query_params: HashMap::new(),
753 headers: HashMap::new(),
754 body: None,
755 };
756
757 let config = K6Config {
758 target_url: "https://api.example.com".to_string(),
759 base_path: None,
760 scenario: LoadScenario::Constant,
761 duration_secs: 30,
762 max_vus: 5,
763 threshold_percentile: "p(95)".to_string(),
764 threshold_ms: 500,
765 max_error_rate: 0.05,
766 auth_header: None,
767 custom_headers: HashMap::new(),
768 skip_tls_verify: true,
769 security_testing_enabled: false,
770 };
771
772 let generator = K6ScriptGenerator::new(config, vec![template]);
773 let script = generator.generate().expect("Should generate script");
774
775 assert!(
777 script.contains("insecureSkipTLSVerify: true"),
778 "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
779 );
780 }
781
782 #[test]
783 fn test_no_skip_tls_verify() {
784 use crate::spec_parser::ApiOperation;
785 use openapiv3::Operation;
786
787 let operation = ApiOperation {
789 method: "get".to_string(),
790 path: "/api/users".to_string(),
791 operation: Operation::default(),
792 operation_id: Some("getUsers".to_string()),
793 };
794
795 let template = RequestTemplate {
796 operation,
797 path_params: HashMap::new(),
798 query_params: HashMap::new(),
799 headers: HashMap::new(),
800 body: None,
801 };
802
803 let config = K6Config {
804 target_url: "https://api.example.com".to_string(),
805 base_path: None,
806 scenario: LoadScenario::Constant,
807 duration_secs: 30,
808 max_vus: 5,
809 threshold_percentile: "p(95)".to_string(),
810 threshold_ms: 500,
811 max_error_rate: 0.05,
812 auth_header: None,
813 custom_headers: HashMap::new(),
814 skip_tls_verify: false,
815 security_testing_enabled: false,
816 };
817
818 let generator = K6ScriptGenerator::new(config, vec![template]);
819 let script = generator.generate().expect("Should generate script");
820
821 assert!(
823 !script.contains("insecureSkipTLSVerify"),
824 "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
825 );
826 }
827
828 #[test]
829 fn test_skip_tls_verify_multiple_operations() {
830 use crate::spec_parser::ApiOperation;
831 use openapiv3::Operation;
832 use serde_json::json;
833
834 let operation1 = ApiOperation {
836 method: "get".to_string(),
837 path: "/api/users".to_string(),
838 operation: Operation::default(),
839 operation_id: Some("getUsers".to_string()),
840 };
841
842 let operation2 = ApiOperation {
843 method: "post".to_string(),
844 path: "/api/users".to_string(),
845 operation: Operation::default(),
846 operation_id: Some("createUser".to_string()),
847 };
848
849 let template1 = RequestTemplate {
850 operation: operation1,
851 path_params: HashMap::new(),
852 query_params: HashMap::new(),
853 headers: HashMap::new(),
854 body: None,
855 };
856
857 let template2 = RequestTemplate {
858 operation: operation2,
859 path_params: HashMap::new(),
860 query_params: HashMap::new(),
861 headers: HashMap::new(),
862 body: Some(json!({"name": "test"})),
863 };
864
865 let config = K6Config {
866 target_url: "https://api.example.com".to_string(),
867 base_path: None,
868 scenario: LoadScenario::Constant,
869 duration_secs: 30,
870 max_vus: 5,
871 threshold_percentile: "p(95)".to_string(),
872 threshold_ms: 500,
873 max_error_rate: 0.05,
874 auth_header: None,
875 custom_headers: HashMap::new(),
876 skip_tls_verify: true,
877 security_testing_enabled: false,
878 };
879
880 let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
881 let script = generator.generate().expect("Should generate script");
882
883 let skip_count = script.matches("insecureSkipTLSVerify: true").count();
886 assert_eq!(
887 skip_count, 1,
888 "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
889 );
890
891 let options_start = script.find("export const options = {").expect("Should have options");
893 let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
894 let options_prefix = &script[options_start..scenarios_start];
895 assert!(
896 options_prefix.contains("insecureSkipTLSVerify: true"),
897 "insecureSkipTLSVerify should be in global options block"
898 );
899 }
900
901 #[test]
902 fn test_dynamic_params_in_body() {
903 use crate::spec_parser::ApiOperation;
904 use openapiv3::Operation;
905 use serde_json::json;
906
907 let operation = ApiOperation {
909 method: "post".to_string(),
910 path: "/api/resources".to_string(),
911 operation: Operation::default(),
912 operation_id: Some("createResource".to_string()),
913 };
914
915 let template = RequestTemplate {
916 operation,
917 path_params: HashMap::new(),
918 query_params: HashMap::new(),
919 headers: HashMap::new(),
920 body: Some(json!({
921 "name": "load-test-${__VU}",
922 "iteration": "${__ITER}"
923 })),
924 };
925
926 let config = K6Config {
927 target_url: "https://api.example.com".to_string(),
928 base_path: None,
929 scenario: LoadScenario::Constant,
930 duration_secs: 30,
931 max_vus: 5,
932 threshold_percentile: "p(95)".to_string(),
933 threshold_ms: 500,
934 max_error_rate: 0.05,
935 auth_header: None,
936 custom_headers: HashMap::new(),
937 skip_tls_verify: false,
938 security_testing_enabled: false,
939 };
940
941 let generator = K6ScriptGenerator::new(config, vec![template]);
942 let script = generator.generate().expect("Should generate script");
943
944 assert!(
946 script.contains("Dynamic body with runtime placeholders"),
947 "Script should contain comment about dynamic body"
948 );
949
950 assert!(
952 script.contains("__VU"),
953 "Script should contain __VU reference for dynamic VU-based values"
954 );
955
956 assert!(
958 script.contains("__ITER"),
959 "Script should contain __ITER reference for dynamic iteration values"
960 );
961 }
962
963 #[test]
964 fn test_dynamic_params_with_uuid() {
965 use crate::spec_parser::ApiOperation;
966 use openapiv3::Operation;
967 use serde_json::json;
968
969 let operation = ApiOperation {
971 method: "post".to_string(),
972 path: "/api/resources".to_string(),
973 operation: Operation::default(),
974 operation_id: Some("createResource".to_string()),
975 };
976
977 let template = RequestTemplate {
978 operation,
979 path_params: HashMap::new(),
980 query_params: HashMap::new(),
981 headers: HashMap::new(),
982 body: Some(json!({
983 "id": "${__UUID}"
984 })),
985 };
986
987 let config = K6Config {
988 target_url: "https://api.example.com".to_string(),
989 base_path: None,
990 scenario: LoadScenario::Constant,
991 duration_secs: 30,
992 max_vus: 5,
993 threshold_percentile: "p(95)".to_string(),
994 threshold_ms: 500,
995 max_error_rate: 0.05,
996 auth_header: None,
997 custom_headers: HashMap::new(),
998 skip_tls_verify: false,
999 security_testing_enabled: false,
1000 };
1001
1002 let generator = K6ScriptGenerator::new(config, vec![template]);
1003 let script = generator.generate().expect("Should generate script");
1004
1005 assert!(
1008 !script.contains("k6/experimental/webcrypto"),
1009 "Script should NOT include deprecated k6/experimental/webcrypto import"
1010 );
1011
1012 assert!(
1014 script.contains("crypto.randomUUID()"),
1015 "Script should contain crypto.randomUUID() for UUID placeholder"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_dynamic_params_with_counter() {
1021 use crate::spec_parser::ApiOperation;
1022 use openapiv3::Operation;
1023 use serde_json::json;
1024
1025 let operation = ApiOperation {
1027 method: "post".to_string(),
1028 path: "/api/resources".to_string(),
1029 operation: Operation::default(),
1030 operation_id: Some("createResource".to_string()),
1031 };
1032
1033 let template = RequestTemplate {
1034 operation,
1035 path_params: HashMap::new(),
1036 query_params: HashMap::new(),
1037 headers: HashMap::new(),
1038 body: Some(json!({
1039 "sequence": "${__COUNTER}"
1040 })),
1041 };
1042
1043 let config = K6Config {
1044 target_url: "https://api.example.com".to_string(),
1045 base_path: None,
1046 scenario: LoadScenario::Constant,
1047 duration_secs: 30,
1048 max_vus: 5,
1049 threshold_percentile: "p(95)".to_string(),
1050 threshold_ms: 500,
1051 max_error_rate: 0.05,
1052 auth_header: None,
1053 custom_headers: HashMap::new(),
1054 skip_tls_verify: false,
1055 security_testing_enabled: false,
1056 };
1057
1058 let generator = K6ScriptGenerator::new(config, vec![template]);
1059 let script = generator.generate().expect("Should generate script");
1060
1061 assert!(
1063 script.contains("let globalCounter = 0"),
1064 "Script should include globalCounter initialization when COUNTER placeholder is used"
1065 );
1066
1067 assert!(
1069 script.contains("globalCounter++"),
1070 "Script should contain globalCounter++ for COUNTER placeholder"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_static_body_no_dynamic_marker() {
1076 use crate::spec_parser::ApiOperation;
1077 use openapiv3::Operation;
1078 use serde_json::json;
1079
1080 let operation = ApiOperation {
1082 method: "post".to_string(),
1083 path: "/api/resources".to_string(),
1084 operation: Operation::default(),
1085 operation_id: Some("createResource".to_string()),
1086 };
1087
1088 let template = RequestTemplate {
1089 operation,
1090 path_params: HashMap::new(),
1091 query_params: HashMap::new(),
1092 headers: HashMap::new(),
1093 body: Some(json!({
1094 "name": "static-value",
1095 "count": 42
1096 })),
1097 };
1098
1099 let config = K6Config {
1100 target_url: "https://api.example.com".to_string(),
1101 base_path: None,
1102 scenario: LoadScenario::Constant,
1103 duration_secs: 30,
1104 max_vus: 5,
1105 threshold_percentile: "p(95)".to_string(),
1106 threshold_ms: 500,
1107 max_error_rate: 0.05,
1108 auth_header: None,
1109 custom_headers: HashMap::new(),
1110 skip_tls_verify: false,
1111 security_testing_enabled: false,
1112 };
1113
1114 let generator = K6ScriptGenerator::new(config, vec![template]);
1115 let script = generator.generate().expect("Should generate script");
1116
1117 assert!(
1119 !script.contains("Dynamic body with runtime placeholders"),
1120 "Script should NOT contain dynamic body comment for static body"
1121 );
1122
1123 assert!(
1125 !script.contains("webcrypto"),
1126 "Script should NOT include webcrypto import for static body"
1127 );
1128
1129 assert!(
1131 !script.contains("let globalCounter"),
1132 "Script should NOT include globalCounter for static body"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_security_testing_enabled_generates_calling_code() {
1138 use crate::spec_parser::ApiOperation;
1139 use openapiv3::Operation;
1140 use serde_json::json;
1141
1142 let operation = ApiOperation {
1143 method: "post".to_string(),
1144 path: "/api/users".to_string(),
1145 operation: Operation::default(),
1146 operation_id: Some("createUser".to_string()),
1147 };
1148
1149 let template = RequestTemplate {
1150 operation,
1151 path_params: HashMap::new(),
1152 query_params: HashMap::new(),
1153 headers: HashMap::new(),
1154 body: Some(json!({"name": "test"})),
1155 };
1156
1157 let config = K6Config {
1158 target_url: "https://api.example.com".to_string(),
1159 base_path: None,
1160 scenario: LoadScenario::Constant,
1161 duration_secs: 30,
1162 max_vus: 5,
1163 threshold_percentile: "p(95)".to_string(),
1164 threshold_ms: 500,
1165 max_error_rate: 0.05,
1166 auth_header: None,
1167 custom_headers: HashMap::new(),
1168 skip_tls_verify: false,
1169 security_testing_enabled: true,
1170 };
1171
1172 let generator = K6ScriptGenerator::new(config, vec![template]);
1173 let script = generator.generate().expect("Should generate script");
1174
1175 assert!(
1177 script.contains("getNextSecurityPayload"),
1178 "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
1179 );
1180 assert!(
1181 script.contains("applySecurityPayload"),
1182 "Script should contain applySecurityPayload() call when security_testing_enabled is true"
1183 );
1184 assert!(
1185 script.contains("secPayloadGroup"),
1186 "Script should contain secPayloadGroup variable when security_testing_enabled is true"
1187 );
1188 assert!(
1189 script.contains("secBodyPayload"),
1190 "Script should contain secBodyPayload variable when security_testing_enabled is true"
1191 );
1192 assert!(
1194 script.contains("hasSecCookie"),
1195 "Script should track hasSecCookie for CookieJar conflict avoidance"
1196 );
1197 assert!(
1198 script.contains("secRequestOpts"),
1199 "Script should use secRequestOpts to conditionally skip CookieJar"
1200 );
1201 assert!(
1203 script.contains("const requestHeaders = { ..."),
1204 "Script should spread headers into mutable copy for security payload injection"
1205 );
1206 assert!(
1208 script.contains("secPayload.injectAsPath"),
1209 "Script should check injectAsPath for path-based URI injection"
1210 );
1211 assert!(
1213 script.contains("secBodyPayload.formBody"),
1214 "Script should check formBody for form-encoded body delivery"
1215 );
1216 assert!(
1217 script.contains("application/x-www-form-urlencoded"),
1218 "Script should set Content-Type for form-encoded body"
1219 );
1220 let op_comment_pos =
1222 script.find("// Operation 0:").expect("Should have Operation 0 comment");
1223 let sec_payload_pos = script
1224 .find("const secPayloadGroup = typeof getNextSecurityPayload")
1225 .expect("Should have secPayloadGroup assignment");
1226 assert!(
1227 sec_payload_pos > op_comment_pos,
1228 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_security_testing_disabled_no_calling_code() {
1234 use crate::spec_parser::ApiOperation;
1235 use openapiv3::Operation;
1236 use serde_json::json;
1237
1238 let operation = ApiOperation {
1239 method: "post".to_string(),
1240 path: "/api/users".to_string(),
1241 operation: Operation::default(),
1242 operation_id: Some("createUser".to_string()),
1243 };
1244
1245 let template = RequestTemplate {
1246 operation,
1247 path_params: HashMap::new(),
1248 query_params: HashMap::new(),
1249 headers: HashMap::new(),
1250 body: Some(json!({"name": "test"})),
1251 };
1252
1253 let config = K6Config {
1254 target_url: "https://api.example.com".to_string(),
1255 base_path: None,
1256 scenario: LoadScenario::Constant,
1257 duration_secs: 30,
1258 max_vus: 5,
1259 threshold_percentile: "p(95)".to_string(),
1260 threshold_ms: 500,
1261 max_error_rate: 0.05,
1262 auth_header: None,
1263 custom_headers: HashMap::new(),
1264 skip_tls_verify: false,
1265 security_testing_enabled: false,
1266 };
1267
1268 let generator = K6ScriptGenerator::new(config, vec![template]);
1269 let script = generator.generate().expect("Should generate script");
1270
1271 assert!(
1273 !script.contains("getNextSecurityPayload"),
1274 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1275 );
1276 assert!(
1277 !script.contains("applySecurityPayload"),
1278 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1279 );
1280 assert!(
1281 !script.contains("secPayloadGroup"),
1282 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
1283 );
1284 assert!(
1285 !script.contains("secBodyPayload"),
1286 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
1287 );
1288 assert!(
1289 !script.contains("hasSecCookie"),
1290 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
1291 );
1292 assert!(
1293 !script.contains("secRequestOpts"),
1294 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
1295 );
1296 assert!(
1297 !script.contains("injectAsPath"),
1298 "Script should NOT contain injectAsPath when security_testing_enabled is false"
1299 );
1300 assert!(
1301 !script.contains("formBody"),
1302 "Script should NOT contain formBody when security_testing_enabled is false"
1303 );
1304 }
1305
1306 #[test]
1310 fn test_security_e2e_definitions_and_calls_both_present() {
1311 use crate::security_payloads::{
1312 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1313 };
1314 use crate::spec_parser::ApiOperation;
1315 use openapiv3::Operation;
1316 use serde_json::json;
1317
1318 let operation = ApiOperation {
1320 method: "post".to_string(),
1321 path: "/api/users".to_string(),
1322 operation: Operation::default(),
1323 operation_id: Some("createUser".to_string()),
1324 };
1325
1326 let template = RequestTemplate {
1327 operation,
1328 path_params: HashMap::new(),
1329 query_params: HashMap::new(),
1330 headers: HashMap::new(),
1331 body: Some(json!({"name": "test"})),
1332 };
1333
1334 let config = K6Config {
1335 target_url: "https://api.example.com".to_string(),
1336 base_path: None,
1337 scenario: LoadScenario::Constant,
1338 duration_secs: 30,
1339 max_vus: 5,
1340 threshold_percentile: "p(95)".to_string(),
1341 threshold_ms: 500,
1342 max_error_rate: 0.05,
1343 auth_header: None,
1344 custom_headers: HashMap::new(),
1345 skip_tls_verify: false,
1346 security_testing_enabled: true,
1347 };
1348
1349 let generator = K6ScriptGenerator::new(config, vec![template]);
1350 let mut script = generator.generate().expect("Should generate base script");
1351
1352 let security_config = SecurityTestConfig::default().enable();
1354 let payloads = SecurityPayloads::get_payloads(&security_config);
1355 assert!(!payloads.is_empty(), "Should have built-in payloads");
1356
1357 let mut additional_code = String::new();
1358 additional_code
1359 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1360 additional_code.push('\n');
1361 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1362 additional_code.push('\n');
1363
1364 if let Some(pos) = script.find("export const options") {
1366 script.insert_str(
1367 pos,
1368 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1369 );
1370 }
1371
1372 assert!(
1375 script.contains("function getNextSecurityPayload()"),
1376 "Final script must contain getNextSecurityPayload function DEFINITION"
1377 );
1378 assert!(
1379 script.contains("function applySecurityPayload("),
1380 "Final script must contain applySecurityPayload function DEFINITION"
1381 );
1382 assert!(
1383 script.contains("securityPayloads"),
1384 "Final script must contain securityPayloads array"
1385 );
1386
1387 assert!(
1389 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
1390 "Final script must contain secPayloadGroup assignment (template calling code)"
1391 );
1392 assert!(
1393 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1394 "Final script must contain applySecurityPayload CALL with secBodyPayload"
1395 );
1396 assert!(
1397 script.contains("const requestHeaders = { ..."),
1398 "Final script must spread headers for security payload header injection"
1399 );
1400 assert!(
1401 script.contains("for (const secPayload of secPayloadGroup)"),
1402 "Final script must loop over secPayloadGroup"
1403 );
1404 assert!(
1405 script.contains("secPayload.injectAsPath"),
1406 "Final script must check injectAsPath for path-based URI injection"
1407 );
1408 assert!(
1409 script.contains("secBodyPayload.formBody"),
1410 "Final script must check formBody for form-encoded body delivery"
1411 );
1412
1413 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1415 let call_pos =
1416 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
1417 let options_pos = script.find("export const options").unwrap();
1418 let default_fn_pos = script.find("export default function").unwrap();
1419
1420 assert!(
1421 def_pos < options_pos,
1422 "Function definitions must appear before export const options"
1423 );
1424 assert!(
1425 call_pos > default_fn_pos,
1426 "Calling code must appear inside export default function"
1427 );
1428 }
1429
1430 #[test]
1432 fn test_security_uri_injection_for_get_requests() {
1433 use crate::spec_parser::ApiOperation;
1434 use openapiv3::Operation;
1435
1436 let operation = ApiOperation {
1437 method: "get".to_string(),
1438 path: "/api/users".to_string(),
1439 operation: Operation::default(),
1440 operation_id: Some("listUsers".to_string()),
1441 };
1442
1443 let template = RequestTemplate {
1444 operation,
1445 path_params: HashMap::new(),
1446 query_params: HashMap::new(),
1447 headers: HashMap::new(),
1448 body: None,
1449 };
1450
1451 let config = K6Config {
1452 target_url: "https://api.example.com".to_string(),
1453 base_path: None,
1454 scenario: LoadScenario::Constant,
1455 duration_secs: 30,
1456 max_vus: 5,
1457 threshold_percentile: "p(95)".to_string(),
1458 threshold_ms: 500,
1459 max_error_rate: 0.05,
1460 auth_header: None,
1461 custom_headers: HashMap::new(),
1462 skip_tls_verify: false,
1463 security_testing_enabled: true,
1464 };
1465
1466 let generator = K6ScriptGenerator::new(config, vec![template]);
1467 let script = generator.generate().expect("Should generate script");
1468
1469 assert!(
1471 script.contains("requestUrl"),
1472 "Script should build requestUrl variable for URI payload injection"
1473 );
1474 assert!(
1475 script.contains("secPayload.location === 'uri'"),
1476 "Script should check for URI-location payloads"
1477 );
1478 assert!(
1480 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
1481 "Script should URL-encode security payload in query string for valid HTTP"
1482 );
1483 assert!(
1485 script.contains("secPayload.injectAsPath"),
1486 "Script should check injectAsPath for path-based URI injection"
1487 );
1488 assert!(
1489 script.contains("encodeURI(secPayload.payload)"),
1490 "Script should use encodeURI for path-based injection"
1491 );
1492 assert!(
1494 script.contains("http.get(requestUrl,"),
1495 "GET request should use requestUrl (with URI injection) instead of inline URL"
1496 );
1497 }
1498
1499 #[test]
1501 fn test_security_uri_injection_for_post_requests() {
1502 use crate::spec_parser::ApiOperation;
1503 use openapiv3::Operation;
1504 use serde_json::json;
1505
1506 let operation = ApiOperation {
1507 method: "post".to_string(),
1508 path: "/api/users".to_string(),
1509 operation: Operation::default(),
1510 operation_id: Some("createUser".to_string()),
1511 };
1512
1513 let template = RequestTemplate {
1514 operation,
1515 path_params: HashMap::new(),
1516 query_params: HashMap::new(),
1517 headers: HashMap::new(),
1518 body: Some(json!({"name": "test"})),
1519 };
1520
1521 let config = K6Config {
1522 target_url: "https://api.example.com".to_string(),
1523 base_path: None,
1524 scenario: LoadScenario::Constant,
1525 duration_secs: 30,
1526 max_vus: 5,
1527 threshold_percentile: "p(95)".to_string(),
1528 threshold_ms: 500,
1529 max_error_rate: 0.05,
1530 auth_header: None,
1531 custom_headers: HashMap::new(),
1532 skip_tls_verify: false,
1533 security_testing_enabled: true,
1534 };
1535
1536 let generator = K6ScriptGenerator::new(config, vec![template]);
1537 let script = generator.generate().expect("Should generate script");
1538
1539 assert!(
1541 script.contains("requestUrl"),
1542 "POST script should build requestUrl for URI payload injection"
1543 );
1544 assert!(
1545 script.contains("secPayload.location === 'uri'"),
1546 "POST script should check for URI-location payloads"
1547 );
1548 assert!(
1549 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1550 "POST script should apply security body payload to request body"
1551 );
1552 assert!(
1554 script.contains("http.post(requestUrl,"),
1555 "POST request should use requestUrl (with URI injection) instead of inline URL"
1556 );
1557 }
1558
1559 #[test]
1561 fn test_no_uri_injection_when_security_disabled() {
1562 use crate::spec_parser::ApiOperation;
1563 use openapiv3::Operation;
1564
1565 let operation = ApiOperation {
1566 method: "get".to_string(),
1567 path: "/api/users".to_string(),
1568 operation: Operation::default(),
1569 operation_id: Some("listUsers".to_string()),
1570 };
1571
1572 let template = RequestTemplate {
1573 operation,
1574 path_params: HashMap::new(),
1575 query_params: HashMap::new(),
1576 headers: HashMap::new(),
1577 body: None,
1578 };
1579
1580 let config = K6Config {
1581 target_url: "https://api.example.com".to_string(),
1582 base_path: None,
1583 scenario: LoadScenario::Constant,
1584 duration_secs: 30,
1585 max_vus: 5,
1586 threshold_percentile: "p(95)".to_string(),
1587 threshold_ms: 500,
1588 max_error_rate: 0.05,
1589 auth_header: None,
1590 custom_headers: HashMap::new(),
1591 skip_tls_verify: false,
1592 security_testing_enabled: false,
1593 };
1594
1595 let generator = K6ScriptGenerator::new(config, vec![template]);
1596 let script = generator.generate().expect("Should generate script");
1597
1598 assert!(
1600 !script.contains("requestUrl"),
1601 "Script should NOT have requestUrl when security is disabled"
1602 );
1603 assert!(
1604 !script.contains("secPayloadGroup"),
1605 "Script should NOT have secPayloadGroup when security is disabled"
1606 );
1607 assert!(
1608 !script.contains("secBodyPayload"),
1609 "Script should NOT have secBodyPayload when security is disabled"
1610 );
1611 }
1612
1613 #[test]
1615 fn test_uses_per_request_cookie_jar() {
1616 use crate::spec_parser::ApiOperation;
1617 use openapiv3::Operation;
1618
1619 let operation = ApiOperation {
1620 method: "get".to_string(),
1621 path: "/api/users".to_string(),
1622 operation: Operation::default(),
1623 operation_id: Some("listUsers".to_string()),
1624 };
1625
1626 let template = RequestTemplate {
1627 operation,
1628 path_params: HashMap::new(),
1629 query_params: HashMap::new(),
1630 headers: HashMap::new(),
1631 body: None,
1632 };
1633
1634 let config = K6Config {
1635 target_url: "https://api.example.com".to_string(),
1636 base_path: None,
1637 scenario: LoadScenario::Constant,
1638 duration_secs: 30,
1639 max_vus: 5,
1640 threshold_percentile: "p(95)".to_string(),
1641 threshold_ms: 500,
1642 max_error_rate: 0.05,
1643 auth_header: None,
1644 custom_headers: HashMap::new(),
1645 skip_tls_verify: false,
1646 security_testing_enabled: false,
1647 };
1648
1649 let generator = K6ScriptGenerator::new(config, vec![template]);
1650 let script = generator.generate().expect("Should generate script");
1651
1652 assert!(
1654 script.contains("jar: new http.CookieJar()"),
1655 "Script should create fresh CookieJar per request"
1656 );
1657 assert!(
1658 !script.contains("jar: null"),
1659 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
1660 );
1661 assert!(
1662 !script.contains("EMPTY_JAR"),
1663 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
1664 );
1665 }
1666}