Skip to main content

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    pub security_testing_enabled: bool,
27}
28
29/// Generate k6 load test script
30pub struct K6ScriptGenerator {
31    config: K6Config,
32    templates: Vec<RequestTemplate>,
33}
34
35impl K6ScriptGenerator {
36    /// Create a new k6 script generator
37    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
38        Self { config, templates }
39    }
40
41    /// Generate the k6 script
42    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    /// Sanitize a name to be a valid JavaScript identifier
55    ///
56    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
57    /// Ensures the identifier starts with a letter or underscore (not a number).
58    ///
59    /// Examples:
60    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
61    /// - "get user" -> "get_user"
62    /// - "123invalid" -> "_123invalid"
63    pub fn sanitize_js_identifier(name: &str) -> String {
64        let mut result = String::new();
65        let mut chars = name.chars().peekable();
66
67        // Ensure it starts with a letter or underscore (not a number)
68        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                // Replace invalid characters with underscore
79                // Avoid consecutive underscores
80                if !result.ends_with('_') {
81                    result.push('_');
82                }
83            }
84        }
85
86        // Remove trailing underscores
87        result = result.trim_end_matches('_').to_string();
88
89        // If empty after sanitization, use a default name
90        if result.is_empty() {
91            result = "operation".to_string();
92        }
93
94        result
95    }
96
97    /// Build the template data for rendering
98    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        // Get the base path (defaults to empty string if not set)
105        let base_path = self.config.base_path.as_deref().unwrap_or("");
106
107        // Track all placeholders used across all operations
108        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                // metric_name must also be sanitized for k6 metric name validation
118                // k6 metric names must only contain ASCII letters, numbers, or underscores
119                let metric_name = sanitized_name.clone();
120                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
121                let k6_method = match template.operation.method.to_lowercase().as_str() {
122                    "delete" => "del".to_string(),
123                    m => m.to_string(),
124                };
125                // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
126                // Other methods take 3 arguments: http.post(url, body, params)
127                let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
128
129                // Process path for dynamic placeholders
130                // Prepend base_path if configured
131                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                // Process body for dynamic placeholders
141                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,  // Use sanitized name for variable names
152                    "metric_name": metric_name,  // Use sanitized name for metric name strings (k6 validation)
153                    "display_name": display_name,  // Keep original for comments/display
154                    "method": k6_method,  // k6 uses lowercase methods (http.get, http.post, http.del)
155                    "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),  // Returns JSON string for template
158                    "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,  // For correct k6 function signature
162                })
163            })
164            .collect::<Vec<_>>();
165
166        // Get required imports and global initializations based on placeholders used
167        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    /// 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
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        // Verify calling code is generated (not just function definitions)
1080        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        // Verify CookieJar skip when Cookie header payload is present
1097        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        // Verify mutable headers copy for injection
1106        assert!(
1107            script.contains("const requestHeaders = { ..."),
1108            "Script should spread headers into mutable copy for security payload injection"
1109        );
1110        // Verify injectAsPath handling for path-based URI injection
1111        assert!(
1112            script.contains("secPayload.injectAsPath"),
1113            "Script should check injectAsPath for path-based URI injection"
1114        );
1115        // Verify formBody handling for form-encoded body delivery
1116        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        // Verify secPayloadGroup is fetched per-operation (inside operation block), not per-iteration
1125        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        // Verify calling code is NOT generated
1176        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    /// End-to-end test: simulates the real pipeline of template rendering + enhanced script
1211    /// injection. This is what actually runs when a user passes `--security-test`.
1212    /// Verifies that the FINAL script has both function definitions AND calling code.
1213    #[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        // Step 1: Generate base script with security_testing_enabled=true (template renders calling code)
1223        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        // Step 2: Simulate what generate_enhanced_script() does — inject function definitions
1257        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        // Insert definitions before 'export const options' (same as generate_enhanced_script)
1269        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        // Step 3: Verify the FINAL script has BOTH definitions AND calls
1277        // Function definitions (injected by generate_enhanced_script)
1278        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        // Calling code (rendered by template)
1292        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        // Verify ordering: definitions come BEFORE export default function (which has the calls)
1318        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 that URI security payload injection is generated for GET requests
1335    #[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        // Verify URI injection code is present for GET requests
1374        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        // URI payloads are URL-encoded for valid HTTP; WAF decodes before inspection
1383        assert!(
1384            script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
1385            "Script should URL-encode security payload in query string for valid HTTP"
1386        );
1387        // Verify injectAsPath check for path-based injection
1388        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        // Verify the GET request uses requestUrl
1397        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 that URI security payload injection is generated for POST requests with body
1404    #[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        // POST with body should get BOTH URI injection AND body injection
1444        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        // Verify the POST request uses requestUrl
1457        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 that security is disabled - no URI injection code present
1464    #[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        // Verify NO security injection code when disabled
1503        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 that scripts create a fresh CookieJar per request (not a shared constant)
1518    #[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        // Each request must create a fresh CookieJar to prevent Set-Cookie accumulation
1557        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}