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 assert!(
1112 script.contains("secPayload.injectAsPath"),
1113 "Script should check injectAsPath for path-based URI injection"
1114 );
1115 assert!(
1117 script.contains("secBodyPayload.formBody"),
1118 "Script should check formBody for form-encoded body delivery"
1119 );
1120 assert!(
1121 script.contains("application/x-www-form-urlencoded"),
1122 "Script should set Content-Type for form-encoded body"
1123 );
1124 let op_comment_pos =
1126 script.find("// Operation 0:").expect("Should have Operation 0 comment");
1127 let sec_payload_pos = script
1128 .find("const secPayloadGroup = typeof getNextSecurityPayload")
1129 .expect("Should have secPayloadGroup assignment");
1130 assert!(
1131 sec_payload_pos > op_comment_pos,
1132 "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_security_testing_disabled_no_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: false,
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 NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1179 );
1180 assert!(
1181 !script.contains("applySecurityPayload"),
1182 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1183 );
1184 assert!(
1185 !script.contains("secPayloadGroup"),
1186 "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
1187 );
1188 assert!(
1189 !script.contains("secBodyPayload"),
1190 "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
1191 );
1192 assert!(
1193 !script.contains("hasSecCookie"),
1194 "Script should NOT contain hasSecCookie when security_testing_enabled is false"
1195 );
1196 assert!(
1197 !script.contains("secRequestOpts"),
1198 "Script should NOT contain secRequestOpts when security_testing_enabled is false"
1199 );
1200 assert!(
1201 !script.contains("injectAsPath"),
1202 "Script should NOT contain injectAsPath when security_testing_enabled is false"
1203 );
1204 assert!(
1205 !script.contains("formBody"),
1206 "Script should NOT contain formBody when security_testing_enabled is false"
1207 );
1208 }
1209
1210 #[test]
1214 fn test_security_e2e_definitions_and_calls_both_present() {
1215 use crate::security_payloads::{
1216 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1217 };
1218 use crate::spec_parser::ApiOperation;
1219 use openapiv3::Operation;
1220 use serde_json::json;
1221
1222 let operation = ApiOperation {
1224 method: "post".to_string(),
1225 path: "/api/users".to_string(),
1226 operation: Operation::default(),
1227 operation_id: Some("createUser".to_string()),
1228 };
1229
1230 let template = RequestTemplate {
1231 operation,
1232 path_params: HashMap::new(),
1233 query_params: HashMap::new(),
1234 headers: HashMap::new(),
1235 body: Some(json!({"name": "test"})),
1236 };
1237
1238 let config = K6Config {
1239 target_url: "https://api.example.com".to_string(),
1240 base_path: None,
1241 scenario: LoadScenario::Constant,
1242 duration_secs: 30,
1243 max_vus: 5,
1244 threshold_percentile: "p(95)".to_string(),
1245 threshold_ms: 500,
1246 max_error_rate: 0.05,
1247 auth_header: None,
1248 custom_headers: HashMap::new(),
1249 skip_tls_verify: false,
1250 security_testing_enabled: true,
1251 };
1252
1253 let generator = K6ScriptGenerator::new(config, vec![template]);
1254 let mut script = generator.generate().expect("Should generate base script");
1255
1256 let security_config = SecurityTestConfig::default().enable();
1258 let payloads = SecurityPayloads::get_payloads(&security_config);
1259 assert!(!payloads.is_empty(), "Should have built-in payloads");
1260
1261 let mut additional_code = String::new();
1262 additional_code
1263 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1264 additional_code.push('\n');
1265 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1266 additional_code.push('\n');
1267
1268 if let Some(pos) = script.find("export const options") {
1270 script.insert_str(
1271 pos,
1272 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1273 );
1274 }
1275
1276 assert!(
1279 script.contains("function getNextSecurityPayload()"),
1280 "Final script must contain getNextSecurityPayload function DEFINITION"
1281 );
1282 assert!(
1283 script.contains("function applySecurityPayload("),
1284 "Final script must contain applySecurityPayload function DEFINITION"
1285 );
1286 assert!(
1287 script.contains("securityPayloads"),
1288 "Final script must contain securityPayloads array"
1289 );
1290
1291 assert!(
1293 script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
1294 "Final script must contain secPayloadGroup assignment (template calling code)"
1295 );
1296 assert!(
1297 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1298 "Final script must contain applySecurityPayload CALL with secBodyPayload"
1299 );
1300 assert!(
1301 script.contains("const requestHeaders = { ..."),
1302 "Final script must spread headers for security payload header injection"
1303 );
1304 assert!(
1305 script.contains("for (const secPayload of secPayloadGroup)"),
1306 "Final script must loop over secPayloadGroup"
1307 );
1308 assert!(
1309 script.contains("secPayload.injectAsPath"),
1310 "Final script must check injectAsPath for path-based URI injection"
1311 );
1312 assert!(
1313 script.contains("secBodyPayload.formBody"),
1314 "Final script must check formBody for form-encoded body delivery"
1315 );
1316
1317 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1319 let call_pos =
1320 script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
1321 let options_pos = script.find("export const options").unwrap();
1322 let default_fn_pos = script.find("export default function").unwrap();
1323
1324 assert!(
1325 def_pos < options_pos,
1326 "Function definitions must appear before export const options"
1327 );
1328 assert!(
1329 call_pos > default_fn_pos,
1330 "Calling code must appear inside export default function"
1331 );
1332 }
1333
1334 #[test]
1336 fn test_security_uri_injection_for_get_requests() {
1337 use crate::spec_parser::ApiOperation;
1338 use openapiv3::Operation;
1339
1340 let operation = ApiOperation {
1341 method: "get".to_string(),
1342 path: "/api/users".to_string(),
1343 operation: Operation::default(),
1344 operation_id: Some("listUsers".to_string()),
1345 };
1346
1347 let template = RequestTemplate {
1348 operation,
1349 path_params: HashMap::new(),
1350 query_params: HashMap::new(),
1351 headers: HashMap::new(),
1352 body: None,
1353 };
1354
1355 let config = K6Config {
1356 target_url: "https://api.example.com".to_string(),
1357 base_path: None,
1358 scenario: LoadScenario::Constant,
1359 duration_secs: 30,
1360 max_vus: 5,
1361 threshold_percentile: "p(95)".to_string(),
1362 threshold_ms: 500,
1363 max_error_rate: 0.05,
1364 auth_header: None,
1365 custom_headers: HashMap::new(),
1366 skip_tls_verify: false,
1367 security_testing_enabled: true,
1368 };
1369
1370 let generator = K6ScriptGenerator::new(config, vec![template]);
1371 let script = generator.generate().expect("Should generate script");
1372
1373 assert!(
1375 script.contains("requestUrl"),
1376 "Script should build requestUrl variable for URI payload injection"
1377 );
1378 assert!(
1379 script.contains("secPayload.location === 'uri'"),
1380 "Script should check for URI-location payloads"
1381 );
1382 assert!(
1384 script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
1385 "Script should URL-encode security payload in query string for valid HTTP"
1386 );
1387 assert!(
1389 script.contains("secPayload.injectAsPath"),
1390 "Script should check injectAsPath for path-based URI injection"
1391 );
1392 assert!(
1393 script.contains("encodeURI(secPayload.payload)"),
1394 "Script should use encodeURI for path-based injection"
1395 );
1396 assert!(
1398 script.contains("http.get(requestUrl,"),
1399 "GET request should use requestUrl (with URI injection) instead of inline URL"
1400 );
1401 }
1402
1403 #[test]
1405 fn test_security_uri_injection_for_post_requests() {
1406 use crate::spec_parser::ApiOperation;
1407 use openapiv3::Operation;
1408 use serde_json::json;
1409
1410 let operation = ApiOperation {
1411 method: "post".to_string(),
1412 path: "/api/users".to_string(),
1413 operation: Operation::default(),
1414 operation_id: Some("createUser".to_string()),
1415 };
1416
1417 let template = RequestTemplate {
1418 operation,
1419 path_params: HashMap::new(),
1420 query_params: HashMap::new(),
1421 headers: HashMap::new(),
1422 body: Some(json!({"name": "test"})),
1423 };
1424
1425 let config = K6Config {
1426 target_url: "https://api.example.com".to_string(),
1427 base_path: None,
1428 scenario: LoadScenario::Constant,
1429 duration_secs: 30,
1430 max_vus: 5,
1431 threshold_percentile: "p(95)".to_string(),
1432 threshold_ms: 500,
1433 max_error_rate: 0.05,
1434 auth_header: None,
1435 custom_headers: HashMap::new(),
1436 skip_tls_verify: false,
1437 security_testing_enabled: true,
1438 };
1439
1440 let generator = K6ScriptGenerator::new(config, vec![template]);
1441 let script = generator.generate().expect("Should generate script");
1442
1443 assert!(
1445 script.contains("requestUrl"),
1446 "POST script should build requestUrl for URI payload injection"
1447 );
1448 assert!(
1449 script.contains("secPayload.location === 'uri'"),
1450 "POST script should check for URI-location payloads"
1451 );
1452 assert!(
1453 script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1454 "POST script should apply security body payload to request body"
1455 );
1456 assert!(
1458 script.contains("http.post(requestUrl,"),
1459 "POST request should use requestUrl (with URI injection) instead of inline URL"
1460 );
1461 }
1462
1463 #[test]
1465 fn test_no_uri_injection_when_security_disabled() {
1466 use crate::spec_parser::ApiOperation;
1467 use openapiv3::Operation;
1468
1469 let operation = ApiOperation {
1470 method: "get".to_string(),
1471 path: "/api/users".to_string(),
1472 operation: Operation::default(),
1473 operation_id: Some("listUsers".to_string()),
1474 };
1475
1476 let template = RequestTemplate {
1477 operation,
1478 path_params: HashMap::new(),
1479 query_params: HashMap::new(),
1480 headers: HashMap::new(),
1481 body: None,
1482 };
1483
1484 let config = K6Config {
1485 target_url: "https://api.example.com".to_string(),
1486 base_path: None,
1487 scenario: LoadScenario::Constant,
1488 duration_secs: 30,
1489 max_vus: 5,
1490 threshold_percentile: "p(95)".to_string(),
1491 threshold_ms: 500,
1492 max_error_rate: 0.05,
1493 auth_header: None,
1494 custom_headers: HashMap::new(),
1495 skip_tls_verify: false,
1496 security_testing_enabled: false,
1497 };
1498
1499 let generator = K6ScriptGenerator::new(config, vec![template]);
1500 let script = generator.generate().expect("Should generate script");
1501
1502 assert!(
1504 !script.contains("requestUrl"),
1505 "Script should NOT have requestUrl when security is disabled"
1506 );
1507 assert!(
1508 !script.contains("secPayloadGroup"),
1509 "Script should NOT have secPayloadGroup when security is disabled"
1510 );
1511 assert!(
1512 !script.contains("secBodyPayload"),
1513 "Script should NOT have secBodyPayload when security is disabled"
1514 );
1515 }
1516
1517 #[test]
1519 fn test_uses_per_request_cookie_jar() {
1520 use crate::spec_parser::ApiOperation;
1521 use openapiv3::Operation;
1522
1523 let operation = ApiOperation {
1524 method: "get".to_string(),
1525 path: "/api/users".to_string(),
1526 operation: Operation::default(),
1527 operation_id: Some("listUsers".to_string()),
1528 };
1529
1530 let template = RequestTemplate {
1531 operation,
1532 path_params: HashMap::new(),
1533 query_params: HashMap::new(),
1534 headers: HashMap::new(),
1535 body: None,
1536 };
1537
1538 let config = K6Config {
1539 target_url: "https://api.example.com".to_string(),
1540 base_path: None,
1541 scenario: LoadScenario::Constant,
1542 duration_secs: 30,
1543 max_vus: 5,
1544 threshold_percentile: "p(95)".to_string(),
1545 threshold_ms: 500,
1546 max_error_rate: 0.05,
1547 auth_header: None,
1548 custom_headers: HashMap::new(),
1549 skip_tls_verify: false,
1550 security_testing_enabled: false,
1551 };
1552
1553 let generator = K6ScriptGenerator::new(config, vec![template]);
1554 let script = generator.generate().expect("Should generate script");
1555
1556 assert!(
1558 script.contains("jar: new http.CookieJar()"),
1559 "Script should create fresh CookieJar per request"
1560 );
1561 assert!(
1562 !script.contains("jar: null"),
1563 "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
1564 );
1565 assert!(
1566 !script.contains("EMPTY_JAR"),
1567 "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
1568 );
1569 }
1570}