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("secPayload"),
1090 "Script should contain secPayload variable when security_testing_enabled is true"
1091 );
1092 assert!(
1094 script.contains("const requestHeaders = { ..."),
1095 "Script should spread headers into mutable copy for security payload injection"
1096 );
1097 let op_comment_pos =
1100 script.find("// Operation 0:").expect("Should have Operation 0 comment");
1101 let sec_payload_pos = script
1102 .find("const secPayload = typeof getNextSecurityPayload")
1103 .expect("Should have secPayload assignment");
1104 assert!(
1105 sec_payload_pos > op_comment_pos,
1106 "secPayload should be fetched inside operation block (per-operation), not before it (per-iteration)"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_security_testing_disabled_no_calling_code() {
1112 use crate::spec_parser::ApiOperation;
1113 use openapiv3::Operation;
1114 use serde_json::json;
1115
1116 let operation = ApiOperation {
1117 method: "post".to_string(),
1118 path: "/api/users".to_string(),
1119 operation: Operation::default(),
1120 operation_id: Some("createUser".to_string()),
1121 };
1122
1123 let template = RequestTemplate {
1124 operation,
1125 path_params: HashMap::new(),
1126 query_params: HashMap::new(),
1127 headers: HashMap::new(),
1128 body: Some(json!({"name": "test"})),
1129 };
1130
1131 let config = K6Config {
1132 target_url: "https://api.example.com".to_string(),
1133 base_path: None,
1134 scenario: LoadScenario::Constant,
1135 duration_secs: 30,
1136 max_vus: 5,
1137 threshold_percentile: "p(95)".to_string(),
1138 threshold_ms: 500,
1139 max_error_rate: 0.05,
1140 auth_header: None,
1141 custom_headers: HashMap::new(),
1142 skip_tls_verify: false,
1143 security_testing_enabled: false,
1144 };
1145
1146 let generator = K6ScriptGenerator::new(config, vec![template]);
1147 let script = generator.generate().expect("Should generate script");
1148
1149 assert!(
1151 !script.contains("getNextSecurityPayload"),
1152 "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1153 );
1154 assert!(
1155 !script.contains("applySecurityPayload"),
1156 "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1157 );
1158 assert!(
1159 !script.contains("secPayload"),
1160 "Script should NOT contain secPayload variable when security_testing_enabled is false"
1161 );
1162 }
1163
1164 #[test]
1168 fn test_security_e2e_definitions_and_calls_both_present() {
1169 use crate::security_payloads::{
1170 SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1171 };
1172 use crate::spec_parser::ApiOperation;
1173 use openapiv3::Operation;
1174 use serde_json::json;
1175
1176 let operation = ApiOperation {
1178 method: "post".to_string(),
1179 path: "/api/users".to_string(),
1180 operation: Operation::default(),
1181 operation_id: Some("createUser".to_string()),
1182 };
1183
1184 let template = RequestTemplate {
1185 operation,
1186 path_params: HashMap::new(),
1187 query_params: HashMap::new(),
1188 headers: HashMap::new(),
1189 body: Some(json!({"name": "test"})),
1190 };
1191
1192 let config = K6Config {
1193 target_url: "https://api.example.com".to_string(),
1194 base_path: None,
1195 scenario: LoadScenario::Constant,
1196 duration_secs: 30,
1197 max_vus: 5,
1198 threshold_percentile: "p(95)".to_string(),
1199 threshold_ms: 500,
1200 max_error_rate: 0.05,
1201 auth_header: None,
1202 custom_headers: HashMap::new(),
1203 skip_tls_verify: false,
1204 security_testing_enabled: true,
1205 };
1206
1207 let generator = K6ScriptGenerator::new(config, vec![template]);
1208 let mut script = generator.generate().expect("Should generate base script");
1209
1210 let security_config = SecurityTestConfig::default().enable();
1212 let payloads = SecurityPayloads::get_payloads(&security_config);
1213 assert!(!payloads.is_empty(), "Should have built-in payloads");
1214
1215 let mut additional_code = String::new();
1216 additional_code
1217 .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1218 additional_code.push('\n');
1219 additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1220 additional_code.push('\n');
1221
1222 if let Some(pos) = script.find("export const options") {
1224 script.insert_str(
1225 pos,
1226 &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1227 );
1228 }
1229
1230 assert!(
1233 script.contains("function getNextSecurityPayload()"),
1234 "Final script must contain getNextSecurityPayload function DEFINITION"
1235 );
1236 assert!(
1237 script.contains("function applySecurityPayload("),
1238 "Final script must contain applySecurityPayload function DEFINITION"
1239 );
1240 assert!(
1241 script.contains("securityPayloads"),
1242 "Final script must contain securityPayloads array"
1243 );
1244
1245 assert!(
1247 script.contains("const secPayload = typeof getNextSecurityPayload"),
1248 "Final script must contain secPayload assignment (template calling code)"
1249 );
1250 assert!(
1251 script.contains("applySecurityPayload(payload, [], secPayload)"),
1252 "Final script must contain applySecurityPayload CALL in request body injection"
1253 );
1254 assert!(
1255 script.contains("const requestHeaders = { ..."),
1256 "Final script must spread headers for security payload header injection"
1257 );
1258
1259 let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1261 let call_pos = script.find("const secPayload = typeof getNextSecurityPayload").unwrap();
1262 let options_pos = script.find("export const options").unwrap();
1263 let default_fn_pos = script.find("export default function").unwrap();
1264
1265 assert!(
1266 def_pos < options_pos,
1267 "Function definitions must appear before export const options"
1268 );
1269 assert!(
1270 call_pos > default_fn_pos,
1271 "Calling code must appear inside export default function"
1272 );
1273 }
1274
1275 #[test]
1277 fn test_security_uri_injection_for_get_requests() {
1278 use crate::spec_parser::ApiOperation;
1279 use openapiv3::Operation;
1280
1281 let operation = ApiOperation {
1282 method: "get".to_string(),
1283 path: "/api/users".to_string(),
1284 operation: Operation::default(),
1285 operation_id: Some("listUsers".to_string()),
1286 };
1287
1288 let template = RequestTemplate {
1289 operation,
1290 path_params: HashMap::new(),
1291 query_params: HashMap::new(),
1292 headers: HashMap::new(),
1293 body: None,
1294 };
1295
1296 let config = K6Config {
1297 target_url: "https://api.example.com".to_string(),
1298 base_path: None,
1299 scenario: LoadScenario::Constant,
1300 duration_secs: 30,
1301 max_vus: 5,
1302 threshold_percentile: "p(95)".to_string(),
1303 threshold_ms: 500,
1304 max_error_rate: 0.05,
1305 auth_header: None,
1306 custom_headers: HashMap::new(),
1307 skip_tls_verify: false,
1308 security_testing_enabled: true,
1309 };
1310
1311 let generator = K6ScriptGenerator::new(config, vec![template]);
1312 let script = generator.generate().expect("Should generate script");
1313
1314 assert!(
1316 script.contains("requestUrl"),
1317 "Script should build requestUrl variable for URI payload injection"
1318 );
1319 assert!(
1320 script.contains("secPayload.location === 'uri'"),
1321 "Script should check for URI-location payloads"
1322 );
1323 assert!(
1324 script.contains("encodeURIComponent(secPayload.payload)"),
1325 "Script should URL-encode the security payload for query string injection"
1326 );
1327 assert!(
1329 script.contains("http.get(requestUrl,"),
1330 "GET request should use requestUrl (with URI injection) instead of inline URL"
1331 );
1332 }
1333
1334 #[test]
1336 fn test_security_uri_injection_for_post_requests() {
1337 use crate::spec_parser::ApiOperation;
1338 use openapiv3::Operation;
1339 use serde_json::json;
1340
1341 let operation = ApiOperation {
1342 method: "post".to_string(),
1343 path: "/api/users".to_string(),
1344 operation: Operation::default(),
1345 operation_id: Some("createUser".to_string()),
1346 };
1347
1348 let template = RequestTemplate {
1349 operation,
1350 path_params: HashMap::new(),
1351 query_params: HashMap::new(),
1352 headers: HashMap::new(),
1353 body: Some(json!({"name": "test"})),
1354 };
1355
1356 let config = K6Config {
1357 target_url: "https://api.example.com".to_string(),
1358 base_path: None,
1359 scenario: LoadScenario::Constant,
1360 duration_secs: 30,
1361 max_vus: 5,
1362 threshold_percentile: "p(95)".to_string(),
1363 threshold_ms: 500,
1364 max_error_rate: 0.05,
1365 auth_header: None,
1366 custom_headers: HashMap::new(),
1367 skip_tls_verify: false,
1368 security_testing_enabled: true,
1369 };
1370
1371 let generator = K6ScriptGenerator::new(config, vec![template]);
1372 let script = generator.generate().expect("Should generate script");
1373
1374 assert!(
1376 script.contains("requestUrl"),
1377 "POST script should build requestUrl for URI payload injection"
1378 );
1379 assert!(
1380 script.contains("secPayload.location === 'uri'"),
1381 "POST script should check for URI-location payloads"
1382 );
1383 assert!(
1384 script.contains("applySecurityPayload(payload, [], secPayload)"),
1385 "POST script should also apply security payload to request body"
1386 );
1387 assert!(
1389 script.contains("http.post(requestUrl,"),
1390 "POST request should use requestUrl (with URI injection) instead of inline URL"
1391 );
1392 }
1393
1394 #[test]
1396 fn test_no_uri_injection_when_security_disabled() {
1397 use crate::spec_parser::ApiOperation;
1398 use openapiv3::Operation;
1399
1400 let operation = ApiOperation {
1401 method: "get".to_string(),
1402 path: "/api/users".to_string(),
1403 operation: Operation::default(),
1404 operation_id: Some("listUsers".to_string()),
1405 };
1406
1407 let template = RequestTemplate {
1408 operation,
1409 path_params: HashMap::new(),
1410 query_params: HashMap::new(),
1411 headers: HashMap::new(),
1412 body: None,
1413 };
1414
1415 let config = K6Config {
1416 target_url: "https://api.example.com".to_string(),
1417 base_path: None,
1418 scenario: LoadScenario::Constant,
1419 duration_secs: 30,
1420 max_vus: 5,
1421 threshold_percentile: "p(95)".to_string(),
1422 threshold_ms: 500,
1423 max_error_rate: 0.05,
1424 auth_header: None,
1425 custom_headers: HashMap::new(),
1426 skip_tls_verify: false,
1427 security_testing_enabled: false,
1428 };
1429
1430 let generator = K6ScriptGenerator::new(config, vec![template]);
1431 let script = generator.generate().expect("Should generate script");
1432
1433 assert!(
1435 !script.contains("requestUrl"),
1436 "Script should NOT have requestUrl when security is disabled"
1437 );
1438 assert!(
1439 !script.contains("secPayload"),
1440 "Script should NOT have secPayload when security is disabled"
1441 );
1442 }
1443
1444 #[test]
1446 fn test_cookie_jar_cleared_with_custom_headers() {
1447 use crate::spec_parser::ApiOperation;
1448 use openapiv3::Operation;
1449
1450 let operation = ApiOperation {
1451 method: "get".to_string(),
1452 path: "/api/users".to_string(),
1453 operation: Operation::default(),
1454 operation_id: Some("listUsers".to_string()),
1455 };
1456
1457 let template = RequestTemplate {
1458 operation,
1459 path_params: HashMap::new(),
1460 query_params: HashMap::new(),
1461 headers: HashMap::new(),
1462 body: None,
1463 };
1464
1465 let mut custom_headers = HashMap::new();
1466 custom_headers.insert("Cookie".to_string(), "session=abc123".to_string());
1467
1468 let config = K6Config {
1469 target_url: "https://api.example.com".to_string(),
1470 base_path: None,
1471 scenario: LoadScenario::Constant,
1472 duration_secs: 30,
1473 max_vus: 5,
1474 threshold_percentile: "p(95)".to_string(),
1475 threshold_ms: 500,
1476 max_error_rate: 0.05,
1477 auth_header: None,
1478 custom_headers,
1479 skip_tls_verify: false,
1480 security_testing_enabled: false,
1481 };
1482
1483 let generator = K6ScriptGenerator::new(config, vec![template]);
1484 let script = generator.generate().expect("Should generate script");
1485
1486 assert!(
1487 script.contains("http.cookieJar().clear(BASE_URL)"),
1488 "Script should clear cookie jar when custom headers are present to prevent duplication"
1489 );
1490 }
1491
1492 #[test]
1494 fn test_no_cookie_jar_clear_without_custom_headers() {
1495 use crate::spec_parser::ApiOperation;
1496 use openapiv3::Operation;
1497
1498 let operation = ApiOperation {
1499 method: "get".to_string(),
1500 path: "/api/users".to_string(),
1501 operation: Operation::default(),
1502 operation_id: Some("listUsers".to_string()),
1503 };
1504
1505 let template = RequestTemplate {
1506 operation,
1507 path_params: HashMap::new(),
1508 query_params: HashMap::new(),
1509 headers: HashMap::new(),
1510 body: None,
1511 };
1512
1513 let config = K6Config {
1514 target_url: "https://api.example.com".to_string(),
1515 base_path: None,
1516 scenario: LoadScenario::Constant,
1517 duration_secs: 30,
1518 max_vus: 5,
1519 threshold_percentile: "p(95)".to_string(),
1520 threshold_ms: 500,
1521 max_error_rate: 0.05,
1522 auth_header: None,
1523 custom_headers: HashMap::new(),
1524 skip_tls_verify: false,
1525 security_testing_enabled: false,
1526 };
1527
1528 let generator = K6ScriptGenerator::new(config, vec![template]);
1529 let script = generator.generate().expect("Should generate script");
1530
1531 assert!(
1532 !script.contains("cookieJar"),
1533 "Script should NOT clear cookie jar when no custom headers are present"
1534 );
1535 }
1536}