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