mockforge_bench/
k6_gen.rs

1//! k6 script generation for load testing real endpoints
2
3use 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
11/// Configuration for k6 script generation
12pub struct K6Config {
13    pub target_url: String,
14    /// API base path prefix (e.g., "/api" or "/v2")
15    /// Prepended to all API endpoint paths
16    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    /// Enable security testing (WAFBench/security payloads injection)
27    pub security_testing_enabled: bool,
28}
29
30/// Generate k6 load test script
31pub struct K6ScriptGenerator {
32    config: K6Config,
33    templates: Vec<RequestTemplate>,
34}
35
36impl K6ScriptGenerator {
37    /// Create a new k6 script generator
38    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
39        Self { config, templates }
40    }
41
42    /// Generate the k6 script
43    pub fn generate(&self) -> Result<String> {
44        let handlebars = Handlebars::new();
45
46        let template = include_str!("templates/k6_script.hbs");
47
48        let data = self.build_template_data()?;
49
50        handlebars
51            .render_template(template, &data)
52            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
53    }
54
55    /// Sanitize a name to be a valid JavaScript identifier
56    ///
57    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
58    /// Ensures the identifier starts with a letter or underscore (not a number).
59    ///
60    /// Examples:
61    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
62    /// - "get user" -> "get_user"
63    /// - "123invalid" -> "_123invalid"
64    pub fn sanitize_js_identifier(name: &str) -> String {
65        let mut result = String::new();
66        let mut chars = name.chars().peekable();
67
68        // Ensure it starts with a letter or underscore (not a number)
69        if let Some(&first) = chars.peek() {
70            if first.is_ascii_digit() {
71                result.push('_');
72            }
73        }
74
75        for ch in chars {
76            if ch.is_ascii_alphanumeric() || ch == '_' {
77                result.push(ch);
78            } else {
79                // Replace invalid characters with underscore
80                // Avoid consecutive underscores
81                if !result.ends_with('_') {
82                    result.push('_');
83                }
84            }
85        }
86
87        // Remove trailing underscores
88        result = result.trim_end_matches('_').to_string();
89
90        // If empty after sanitization, use a default name
91        if result.is_empty() {
92            result = "operation".to_string();
93        }
94
95        result
96    }
97
98    /// Build the template data for rendering
99    fn build_template_data(&self) -> Result<Value> {
100        let stages = self
101            .config
102            .scenario
103            .generate_stages(self.config.duration_secs, self.config.max_vus);
104
105        // Get the base path (defaults to empty string if not set)
106        let base_path = self.config.base_path.as_deref().unwrap_or("");
107
108        // Track all placeholders used across all operations
109        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
110
111        let operations = self
112            .templates
113            .iter()
114            .enumerate()
115            .map(|(idx, template)| {
116                let display_name = template.operation.display_name();
117                let sanitized_name = Self::sanitize_js_identifier(&display_name);
118                // metric_name must also be sanitized for k6 metric name validation
119                // k6 metric names must only contain ASCII letters, numbers, or underscores
120                let metric_name = sanitized_name.clone();
121                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
122                let k6_method = match template.operation.method.to_lowercase().as_str() {
123                    "delete" => "del".to_string(),
124                    m => m.to_string(),
125                };
126                // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
127                // Other methods take 3 arguments: http.post(url, body, params)
128                let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
129
130                // Process path for dynamic placeholders
131                // Prepend base_path if configured
132                let raw_path = template.generate_path();
133                let full_path = if base_path.is_empty() {
134                    raw_path
135                } else {
136                    format!("{}{}", base_path, raw_path)
137                };
138                let processed_path = DynamicParamProcessor::process_path(&full_path);
139                all_placeholders.extend(processed_path.placeholders.clone());
140
141                // Process body for dynamic placeholders
142                let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
143                    let processed_body = DynamicParamProcessor::process_json_body(body);
144                    all_placeholders.extend(processed_body.placeholders.clone());
145                    (Some(processed_body.value), processed_body.is_dynamic)
146                } else {
147                    (None, false)
148                };
149
150                json!({
151                    "index": idx,
152                    "name": sanitized_name,  // Use sanitized name for variable names
153                    "metric_name": metric_name,  // Use sanitized name for metric name strings (k6 validation)
154                    "display_name": display_name,  // Keep original for comments/display
155                    "method": k6_method,  // k6 uses lowercase methods (http.get, http.post, http.del)
156                    "path": if processed_path.is_dynamic { processed_path.value } else { full_path },
157                    "path_is_dynamic": processed_path.is_dynamic,
158                    "headers": self.build_headers_json(template),  // Returns JSON string for template
159                    "body": body_value,
160                    "body_is_dynamic": body_is_dynamic,
161                    "has_body": template.body.is_some(),
162                    "is_get_or_head": is_get_or_head,  // For correct k6 function signature
163                })
164            })
165            .collect::<Vec<_>>();
166
167        // Get required imports and global initializations based on placeholders used
168        let required_imports = DynamicParamProcessor::get_required_imports(&all_placeholders);
169        let required_globals = DynamicParamProcessor::get_required_globals(&all_placeholders);
170        let has_dynamic_values = !all_placeholders.is_empty();
171
172        Ok(json!({
173            "base_url": self.config.target_url,
174            "stages": stages.iter().map(|s| json!({
175                "duration": s.duration,
176                "target": s.target,
177            })).collect::<Vec<_>>(),
178            "operations": operations,
179            "threshold_percentile": self.config.threshold_percentile,
180            "threshold_ms": self.config.threshold_ms,
181            "max_error_rate": self.config.max_error_rate,
182            "scenario_name": format!("{:?}", self.config.scenario).to_lowercase(),
183            "skip_tls_verify": self.config.skip_tls_verify,
184            "has_dynamic_values": has_dynamic_values,
185            "dynamic_imports": required_imports,
186            "dynamic_globals": required_globals,
187            "security_testing_enabled": self.config.security_testing_enabled,
188        }))
189    }
190
191    /// Build headers for a request template as a JSON string for k6 script
192    fn build_headers_json(&self, template: &RequestTemplate) -> String {
193        let mut headers = template.get_headers();
194
195        // Add auth header if provided
196        if let Some(auth) = &self.config.auth_header {
197            headers.insert("Authorization".to_string(), auth.clone());
198        }
199
200        // Add custom headers
201        for (key, value) in &self.config.custom_headers {
202            headers.insert(key.clone(), value.clone());
203        }
204
205        // Convert to JSON string for embedding in k6 script
206        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
207    }
208
209    /// Validate the generated k6 script for common issues
210    ///
211    /// Checks for:
212    /// - Invalid metric names (contains dots or special characters)
213    /// - Invalid JavaScript variable names
214    /// - Missing required k6 imports
215    ///
216    /// Returns a list of validation errors, empty if all checks pass.
217    pub fn validate_script(script: &str) -> Vec<String> {
218        let mut errors = Vec::new();
219
220        // Check for required k6 imports
221        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        // Check for invalid metric names in Trend/Rate constructors
232        // k6 metric names must only contain ASCII letters, numbers, or underscores
233        // and start with a letter or underscore
234        let lines: Vec<&str> = script.lines().collect();
235        for (line_num, line) in lines.iter().enumerate() {
236            let trimmed = line.trim();
237
238            // Check for Trend/Rate constructors with invalid metric names
239            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
240                // Extract the metric name from the string literal
241                // Pattern: new Trend('metric_name') or new Rate("metric_name")
242                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            // Check for invalid JavaScript variable names (containing dots)
268            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                    // Check if variable name contains a dot (invalid identifier)
272                    // But exclude string literals
273                    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    /// Check if a string is a valid k6 metric name
292    ///
293    /// k6 metric names must:
294    /// - Only contain ASCII letters, numbers, or underscores
295    /// - Start with a letter or underscore (not a number)
296    /// - Be at most 128 characters
297    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        // First character must be a letter or underscore
305        if let Some(first) = chars.next() {
306            if !first.is_ascii_alphabetic() && first != '_' {
307                return false;
308            }
309        }
310
311        // Remaining characters must be alphanumeric or underscore
312        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        // Test case from issue #79: names with dots
373        assert_eq!(
374            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
375            "billing_subscriptions_v1"
376        );
377
378        // Test other invalid characters
379        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
380
381        // Test names starting with numbers
382        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
383
384        // Test already valid identifiers
385        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
386
387        // Test with multiple consecutive invalid chars
388        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
389
390        // Test empty string (should return default)
391        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
392
393        // Test with special characters
394        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
395
396        // Test CRUD flow names with dots (issue #79 follow-up)
397        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        // Create an operation with a name containing dots (like in issue #79)
412        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        // Verify the script contains sanitized variable names (no dots in variable identifiers)
446        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        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
456        // The pattern "const billing.subscriptions" would indicate a variable name with dots
457        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        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
463        // Metric names must only contain ASCII letters, numbers, or underscores
464        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        // Verify the original display name is still used in comments and strings (for readability)
474        assert!(
475            script.contains("billing.subscriptions.v1"),
476            "Script should contain original name in comments/strings for readability"
477        );
478
479        // Most importantly: verify the variable usage doesn't have dots
480        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        // Test that validate_script correctly identifies invalid metric names
552        // Valid metric names should pass
553        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        // Invalid metric names should fail
565        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        // Create an operation with a request body
600        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        // Verify the script includes TLS skip option for requests with body
634        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        // Create an operation without a request body
646        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        // Verify the script includes TLS skip option for requests without body
680        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        // Create an operation
692        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        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
726        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        // Create multiple operations - one with body, one without
739        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        // Verify the script includes TLS skip option ONCE in global options
788        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
789        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        // Verify it appears in the options block, before scenarios
796        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        // Create an operation with dynamic placeholders in the body
812        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        // Verify the script contains dynamic body indication
849        assert!(
850            script.contains("Dynamic body with runtime placeholders"),
851            "Script should contain comment about dynamic body"
852        );
853
854        // Verify the script contains the __VU variable reference
855        assert!(
856            script.contains("__VU"),
857            "Script should contain __VU reference for dynamic VU-based values"
858        );
859
860        // Verify the script contains the __ITER variable reference
861        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        // Create an operation with UUID placeholder
874        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        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
910        // Verify the script does NOT include the old experimental webcrypto import
911        assert!(
912            !script.contains("k6/experimental/webcrypto"),
913            "Script should NOT include deprecated k6/experimental/webcrypto import"
914        );
915
916        // Verify crypto.randomUUID() is in the generated code
917        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        // Create an operation with COUNTER placeholder
930        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        // Verify the script includes the global counter initialization
966        assert!(
967            script.contains("let globalCounter = 0"),
968            "Script should include globalCounter initialization when COUNTER placeholder is used"
969        );
970
971        // Verify globalCounter++ is in the generated code
972        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        // Create an operation with static body (no placeholders)
985        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        // Verify the script does NOT contain dynamic body marker
1022        assert!(
1023            !script.contains("Dynamic body with runtime placeholders"),
1024            "Script should NOT contain dynamic body comment for static body"
1025        );
1026
1027        // Verify it does NOT include unnecessary crypto imports
1028        assert!(
1029            !script.contains("webcrypto"),
1030            "Script should NOT include webcrypto import for static body"
1031        );
1032
1033        // Verify it does NOT include global counter
1034        assert!(
1035            !script.contains("let globalCounter"),
1036            "Script should NOT include globalCounter for static body"
1037        );
1038    }
1039}