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::Serialize;
9#[cfg(test)]
10use serde_json::json;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13
14/// Typed template data for `k6_script.hbs`.
15///
16/// Every field referenced by `{{variable}}` or `{{#if flag}}` in the template
17/// is a required field here, so the compiler prevents the Issue-#79 class of
18/// bugs (template rendered with missing data).
19#[derive(Debug, Clone, Serialize)]
20pub struct K6ScriptTemplateData {
21    pub base_url: String,
22    pub stages: Vec<K6StageData>,
23    pub operations: Vec<K6OperationData>,
24    pub threshold_percentile: String,
25    pub threshold_ms: u64,
26    pub max_error_rate: f64,
27    pub scenario_name: String,
28    pub skip_tls_verify: bool,
29    pub has_dynamic_values: bool,
30    pub dynamic_imports: Vec<String>,
31    pub dynamic_globals: Vec<String>,
32    pub security_testing_enabled: bool,
33    pub has_custom_headers: bool,
34}
35
36/// Typed template data for `k6_crud_flow.hbs`.
37#[derive(Debug, Clone, Serialize)]
38pub struct K6CrudFlowTemplateData {
39    pub base_url: String,
40    pub flows: Vec<Value>,
41    pub extract_fields: Vec<String>,
42    pub duration_secs: u64,
43    pub max_vus: u32,
44    pub auth_header: Option<String>,
45    pub custom_headers: HashMap<String, String>,
46    pub skip_tls_verify: bool,
47    pub stages: Vec<K6StageData>,
48    pub threshold_percentile: String,
49    pub threshold_ms: u64,
50    pub max_error_rate: f64,
51    /// Raw JSON string for embedding in k6 script (rendered unescaped via `{{{headers}}}`)
52    pub headers: String,
53    pub dynamic_imports: Vec<String>,
54    pub dynamic_globals: Vec<String>,
55    pub extracted_values_output_path: String,
56    pub error_injection_enabled: bool,
57    pub error_rate: f64,
58    pub error_types: Vec<String>,
59    pub security_testing_enabled: bool,
60    pub has_custom_headers: bool,
61}
62
63/// A k6 load stage for template rendering.
64#[derive(Debug, Clone, Serialize)]
65pub struct K6StageData {
66    pub duration: String,
67    pub target: u32,
68}
69
70/// Per-operation data for the `k6_script.hbs` template.
71#[derive(Debug, Clone, Serialize)]
72pub struct K6OperationData {
73    pub index: usize,
74    pub name: String,
75    pub metric_name: String,
76    pub display_name: String,
77    pub method: String,
78    pub path: Value,
79    pub path_is_dynamic: bool,
80    pub headers: Value,
81    pub body: Option<Value>,
82    pub body_is_dynamic: bool,
83    pub has_body: bool,
84    pub is_get_or_head: bool,
85}
86
87/// Configuration for k6 script generation
88pub struct K6Config {
89    pub target_url: String,
90    /// API base path prefix (e.g., "/api" or "/v2")
91    /// Prepended to all API endpoint paths
92    pub base_path: Option<String>,
93    pub scenario: LoadScenario,
94    pub duration_secs: u64,
95    pub max_vus: u32,
96    pub threshold_percentile: String,
97    pub threshold_ms: u64,
98    pub max_error_rate: f64,
99    pub auth_header: Option<String>,
100    pub custom_headers: HashMap<String, String>,
101    pub skip_tls_verify: bool,
102    pub security_testing_enabled: bool,
103}
104
105/// Generate k6 load test script
106pub struct K6ScriptGenerator {
107    config: K6Config,
108    templates: Vec<RequestTemplate>,
109}
110
111impl K6ScriptGenerator {
112    /// Create a new k6 script generator
113    pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
114        Self { config, templates }
115    }
116
117    /// Generate the k6 script
118    pub fn generate(&self) -> Result<String> {
119        let handlebars = Handlebars::new();
120
121        let template = include_str!("templates/k6_script.hbs");
122
123        let data = self.build_template_data()?;
124
125        let value = serde_json::to_value(&data)
126            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
127
128        handlebars
129            .render_template(template, &value)
130            .map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
131    }
132
133    /// Sanitize a name to be a valid JavaScript identifier
134    ///
135    /// Replaces invalid characters (dots, spaces, special chars) with underscores.
136    /// Ensures the identifier starts with a letter or underscore (not a number).
137    ///
138    /// Examples:
139    /// - "billing.subscriptions.v1" -> "billing_subscriptions_v1"
140    /// - "get user" -> "get_user"
141    /// - "123invalid" -> "_123invalid"
142    pub fn sanitize_js_identifier(name: &str) -> String {
143        let mut result = String::new();
144        let mut chars = name.chars().peekable();
145
146        // Ensure it starts with a letter or underscore (not a number)
147        if let Some(&first) = chars.peek() {
148            if first.is_ascii_digit() {
149                result.push('_');
150            }
151        }
152
153        for ch in chars {
154            if ch.is_ascii_alphanumeric() || ch == '_' {
155                result.push(ch);
156            } else {
157                // Replace invalid characters with underscore
158                // Avoid consecutive underscores
159                if !result.ends_with('_') {
160                    result.push('_');
161                }
162            }
163        }
164
165        // Remove trailing underscores
166        result = result.trim_end_matches('_').to_string();
167
168        // If empty after sanitization, use a default name
169        if result.is_empty() {
170            result = "operation".to_string();
171        }
172
173        result
174    }
175
176    /// Build the typed template data for rendering.
177    fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
178        let stages = self
179            .config
180            .scenario
181            .generate_stages(self.config.duration_secs, self.config.max_vus);
182
183        // Get the base path (defaults to empty string if not set)
184        let base_path = self.config.base_path.as_deref().unwrap_or("");
185
186        // Track all placeholders used across all operations
187        let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
188
189        let operations = self
190            .templates
191            .iter()
192            .enumerate()
193            .map(|(idx, template)| {
194                let display_name = template.operation.display_name();
195                let sanitized_name = Self::sanitize_js_identifier(&display_name);
196                // metric_name must also be sanitized for k6 metric name validation
197                // k6 metric names must only contain ASCII letters, numbers, or underscores
198                let metric_name = sanitized_name.clone();
199                // k6 uses 'del' instead of 'delete' for HTTP DELETE method
200                let k6_method = match template.operation.method.to_lowercase().as_str() {
201                    "delete" => "del".to_string(),
202                    m => m.to_string(),
203                };
204                // GET and HEAD methods only take 2 arguments in k6: http.get(url, params)
205                // Other methods take 3 arguments: http.post(url, body, params)
206                let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
207
208                // Process path for dynamic placeholders
209                // Prepend base_path if configured
210                let raw_path = template.generate_path();
211                let full_path = if base_path.is_empty() {
212                    raw_path
213                } else {
214                    format!("{}{}", base_path, raw_path)
215                };
216                let processed_path = DynamicParamProcessor::process_path(&full_path);
217                all_placeholders.extend(processed_path.placeholders.clone());
218
219                // Process body for dynamic placeholders
220                let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
221                    let processed_body = DynamicParamProcessor::process_json_body(body);
222                    all_placeholders.extend(processed_body.placeholders.clone());
223                    (Some(processed_body.value), processed_body.is_dynamic)
224                } else {
225                    (None, false)
226                };
227
228                let path_value = if processed_path.is_dynamic {
229                    processed_path.value
230                } else {
231                    full_path
232                };
233
234                K6OperationData {
235                    index: idx,
236                    name: sanitized_name,
237                    metric_name,
238                    display_name,
239                    method: k6_method,
240                    path: Value::String(path_value),
241                    path_is_dynamic: processed_path.is_dynamic,
242                    headers: Value::String(self.build_headers_json(template)),
243                    body: body_value.map(Value::String),
244                    body_is_dynamic,
245                    has_body: template.body.is_some(),
246                    is_get_or_head,
247                }
248            })
249            .collect::<Vec<_>>();
250
251        // Get required imports and global initializations based on placeholders used
252        let required_imports: Vec<String> =
253            DynamicParamProcessor::get_required_imports(&all_placeholders)
254                .into_iter()
255                .map(String::from)
256                .collect();
257        let required_globals: Vec<String> =
258            DynamicParamProcessor::get_required_globals(&all_placeholders)
259                .into_iter()
260                .map(String::from)
261                .collect();
262        let has_dynamic_values = !all_placeholders.is_empty();
263
264        Ok(K6ScriptTemplateData {
265            base_url: self.config.target_url.clone(),
266            stages: stages
267                .iter()
268                .map(|s| K6StageData {
269                    duration: s.duration.clone(),
270                    target: s.target,
271                })
272                .collect(),
273            operations,
274            threshold_percentile: self.config.threshold_percentile.clone(),
275            threshold_ms: self.config.threshold_ms,
276            max_error_rate: self.config.max_error_rate,
277            scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
278            skip_tls_verify: self.config.skip_tls_verify,
279            has_dynamic_values,
280            dynamic_imports: required_imports,
281            dynamic_globals: required_globals,
282            security_testing_enabled: self.config.security_testing_enabled,
283            has_custom_headers: !self.config.custom_headers.is_empty(),
284        })
285    }
286
287    /// Build headers for a request template as a JSON string for k6 script
288    fn build_headers_json(&self, template: &RequestTemplate) -> String {
289        let mut headers = template.get_headers();
290
291        // Add auth header if provided
292        if let Some(auth) = &self.config.auth_header {
293            headers.insert("Authorization".to_string(), auth.clone());
294        }
295
296        // Add custom headers
297        for (key, value) in &self.config.custom_headers {
298            headers.insert(key.clone(), value.clone());
299        }
300
301        // Convert to JSON string for embedding in k6 script
302        serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
303    }
304
305    /// Validate the generated k6 script for common issues
306    ///
307    /// Checks for:
308    /// - Invalid metric names (contains dots or special characters)
309    /// - Invalid JavaScript variable names
310    /// - Missing required k6 imports
311    ///
312    /// Returns a list of validation errors, empty if all checks pass.
313    pub fn validate_script(script: &str) -> Vec<String> {
314        let mut errors = Vec::new();
315
316        // Check for required k6 imports
317        if !script.contains("import http from 'k6/http'") {
318            errors.push("Missing required import: 'k6/http'".to_string());
319        }
320        if !script.contains("import { check") && !script.contains("import {check") {
321            errors.push("Missing required import: 'check' from 'k6'".to_string());
322        }
323        if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
324            errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
325        }
326
327        // Check for invalid metric names in Trend/Rate constructors
328        // k6 metric names must only contain ASCII letters, numbers, or underscores
329        // and start with a letter or underscore
330        let lines: Vec<&str> = script.lines().collect();
331        for (line_num, line) in lines.iter().enumerate() {
332            let trimmed = line.trim();
333
334            // Check for Trend/Rate constructors with invalid metric names
335            if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
336                // Extract the metric name from the string literal
337                // Pattern: new Trend('metric_name') or new Rate("metric_name")
338                if let Some(start) = trimmed.find('\'') {
339                    if let Some(end) = trimmed[start + 1..].find('\'') {
340                        let metric_name = &trimmed[start + 1..start + 1 + end];
341                        if !Self::is_valid_k6_metric_name(metric_name) {
342                            errors.push(format!(
343                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
344                                line_num + 1,
345                                metric_name
346                            ));
347                        }
348                    }
349                } else if let Some(start) = trimmed.find('"') {
350                    if let Some(end) = trimmed[start + 1..].find('"') {
351                        let metric_name = &trimmed[start + 1..start + 1 + end];
352                        if !Self::is_valid_k6_metric_name(metric_name) {
353                            errors.push(format!(
354                                "Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
355                                line_num + 1,
356                                metric_name
357                            ));
358                        }
359                    }
360                }
361            }
362
363            // Check for invalid JavaScript variable names (containing dots)
364            if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
365                if let Some(equals_pos) = trimmed.find('=') {
366                    let var_decl = &trimmed[..equals_pos];
367                    // Check if variable name contains a dot (invalid identifier)
368                    // But exclude string literals
369                    if var_decl.contains('.')
370                        && !var_decl.contains("'")
371                        && !var_decl.contains("\"")
372                        && !var_decl.trim().starts_with("//")
373                    {
374                        errors.push(format!(
375                            "Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
376                            line_num + 1,
377                            var_decl.trim()
378                        ));
379                    }
380                }
381            }
382        }
383
384        errors
385    }
386
387    /// Check if a string is a valid k6 metric name
388    ///
389    /// k6 metric names must:
390    /// - Only contain ASCII letters, numbers, or underscores
391    /// - Start with a letter or underscore (not a number)
392    /// - Be at most 128 characters
393    fn is_valid_k6_metric_name(name: &str) -> bool {
394        if name.is_empty() || name.len() > 128 {
395            return false;
396        }
397
398        let mut chars = name.chars();
399
400        // First character must be a letter or underscore
401        if let Some(first) = chars.next() {
402            if !first.is_ascii_alphabetic() && first != '_' {
403                return false;
404            }
405        }
406
407        // Remaining characters must be alphanumeric or underscore
408        for ch in chars {
409            if !ch.is_ascii_alphanumeric() && ch != '_' {
410                return false;
411            }
412        }
413
414        true
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_k6_config_creation() {
424        let config = K6Config {
425            target_url: "https://api.example.com".to_string(),
426            base_path: None,
427            scenario: LoadScenario::RampUp,
428            duration_secs: 60,
429            max_vus: 10,
430            threshold_percentile: "p(95)".to_string(),
431            threshold_ms: 500,
432            max_error_rate: 0.05,
433            auth_header: None,
434            custom_headers: HashMap::new(),
435            skip_tls_verify: false,
436            security_testing_enabled: false,
437        };
438
439        assert_eq!(config.duration_secs, 60);
440        assert_eq!(config.max_vus, 10);
441    }
442
443    #[test]
444    fn test_script_generator_creation() {
445        let config = K6Config {
446            target_url: "https://api.example.com".to_string(),
447            base_path: None,
448            scenario: LoadScenario::Constant,
449            duration_secs: 30,
450            max_vus: 5,
451            threshold_percentile: "p(95)".to_string(),
452            threshold_ms: 500,
453            max_error_rate: 0.05,
454            auth_header: None,
455            custom_headers: HashMap::new(),
456            skip_tls_verify: false,
457            security_testing_enabled: false,
458        };
459
460        let templates = vec![];
461        let generator = K6ScriptGenerator::new(config, templates);
462
463        assert_eq!(generator.templates.len(), 0);
464    }
465
466    #[test]
467    fn test_sanitize_js_identifier() {
468        // Test case from issue #79: names with dots
469        assert_eq!(
470            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
471            "billing_subscriptions_v1"
472        );
473
474        // Test other invalid characters
475        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
476
477        // Test names starting with numbers
478        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
479
480        // Test already valid identifiers
481        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
482
483        // Test with multiple consecutive invalid chars
484        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
485
486        // Test empty string (should return default)
487        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
488
489        // Test with special characters
490        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
491
492        // Test CRUD flow names with dots (issue #79 follow-up)
493        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
494        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
495        assert_eq!(
496            K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
497            "plans_update_pricing_schemes"
498        );
499        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
500    }
501
502    #[test]
503    fn test_script_generation_with_dots_in_name() {
504        use crate::spec_parser::ApiOperation;
505        use openapiv3::Operation;
506
507        // Create an operation with a name containing dots (like in issue #79)
508        let operation = ApiOperation {
509            method: "get".to_string(),
510            path: "/billing/subscriptions".to_string(),
511            operation: Operation::default(),
512            operation_id: Some("billing.subscriptions.v1".to_string()),
513        };
514
515        let template = RequestTemplate {
516            operation,
517            path_params: HashMap::new(),
518            query_params: HashMap::new(),
519            headers: HashMap::new(),
520            body: None,
521        };
522
523        let config = K6Config {
524            target_url: "https://api.example.com".to_string(),
525            base_path: None,
526            scenario: LoadScenario::Constant,
527            duration_secs: 30,
528            max_vus: 5,
529            threshold_percentile: "p(95)".to_string(),
530            threshold_ms: 500,
531            max_error_rate: 0.05,
532            auth_header: None,
533            custom_headers: HashMap::new(),
534            skip_tls_verify: false,
535            security_testing_enabled: false,
536        };
537
538        let generator = K6ScriptGenerator::new(config, vec![template]);
539        let script = generator.generate().expect("Should generate script");
540
541        // Verify the script contains sanitized variable names (no dots in variable identifiers)
542        assert!(
543            script.contains("const billing_subscriptions_v1_latency"),
544            "Script should contain sanitized variable name for latency"
545        );
546        assert!(
547            script.contains("const billing_subscriptions_v1_errors"),
548            "Script should contain sanitized variable name for errors"
549        );
550
551        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
552        // The pattern "const billing.subscriptions" would indicate a variable name with dots
553        assert!(
554            !script.contains("const billing.subscriptions"),
555            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
556        );
557
558        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
559        // Metric names must only contain ASCII letters, numbers, or underscores
560        assert!(
561            script.contains("'billing_subscriptions_v1_latency'"),
562            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
563        );
564        assert!(
565            script.contains("'billing_subscriptions_v1_errors'"),
566            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
567        );
568
569        // Verify the original display name is still used in comments and strings (for readability)
570        assert!(
571            script.contains("billing.subscriptions.v1"),
572            "Script should contain original name in comments/strings for readability"
573        );
574
575        // Most importantly: verify the variable usage doesn't have dots
576        assert!(
577            script.contains("billing_subscriptions_v1_latency.add"),
578            "Variable usage should use sanitized name"
579        );
580        assert!(
581            script.contains("billing_subscriptions_v1_errors.add"),
582            "Variable usage should use sanitized name"
583        );
584    }
585
586    #[test]
587    fn test_validate_script_valid() {
588        let valid_script = r#"
589import http from 'k6/http';
590import { check, sleep } from 'k6';
591import { Rate, Trend } from 'k6/metrics';
592
593const test_latency = new Trend('test_latency');
594const test_errors = new Rate('test_errors');
595
596export default function() {
597    const res = http.get('https://example.com');
598    test_latency.add(res.timings.duration);
599    test_errors.add(res.status !== 200);
600}
601"#;
602
603        let errors = K6ScriptGenerator::validate_script(valid_script);
604        assert!(errors.is_empty(), "Valid script should have no validation errors");
605    }
606
607    #[test]
608    fn test_validate_script_invalid_metric_name() {
609        let invalid_script = r#"
610import http from 'k6/http';
611import { check, sleep } from 'k6';
612import { Rate, Trend } from 'k6/metrics';
613
614const test_latency = new Trend('test.latency');
615const test_errors = new Rate('test_errors');
616
617export default function() {
618    const res = http.get('https://example.com');
619    test_latency.add(res.timings.duration);
620}
621"#;
622
623        let errors = K6ScriptGenerator::validate_script(invalid_script);
624        assert!(
625            !errors.is_empty(),
626            "Script with invalid metric name should have validation errors"
627        );
628        assert!(
629            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
630            "Should detect invalid metric name with dot"
631        );
632    }
633
634    #[test]
635    fn test_validate_script_missing_imports() {
636        let invalid_script = r#"
637const test_latency = new Trend('test_latency');
638export default function() {}
639"#;
640
641        let errors = K6ScriptGenerator::validate_script(invalid_script);
642        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
643    }
644
645    #[test]
646    fn test_validate_script_metric_name_validation() {
647        // Test that validate_script correctly identifies invalid metric names
648        // Valid metric names should pass
649        let valid_script = r#"
650import http from 'k6/http';
651import { check, sleep } from 'k6';
652import { Rate, Trend } from 'k6/metrics';
653const test_latency = new Trend('test_latency');
654const test_errors = new Rate('test_errors');
655export default function() {}
656"#;
657        let errors = K6ScriptGenerator::validate_script(valid_script);
658        assert!(errors.is_empty(), "Valid metric names should pass validation");
659
660        // Invalid metric names should fail
661        let invalid_cases = vec![
662            ("test.latency", "dot in metric name"),
663            ("123test", "starts with number"),
664            ("test-latency", "hyphen in metric name"),
665            ("test@latency", "special character"),
666        ];
667
668        for (invalid_name, description) in invalid_cases {
669            let script = format!(
670                r#"
671import http from 'k6/http';
672import {{ check, sleep }} from 'k6';
673import {{ Rate, Trend }} from 'k6/metrics';
674const test_latency = new Trend('{}');
675export default function() {{}}
676"#,
677                invalid_name
678            );
679            let errors = K6ScriptGenerator::validate_script(&script);
680            assert!(
681                !errors.is_empty(),
682                "Metric name '{}' ({}) should fail validation",
683                invalid_name,
684                description
685            );
686        }
687    }
688
689    #[test]
690    fn test_skip_tls_verify_with_body() {
691        use crate::spec_parser::ApiOperation;
692        use openapiv3::Operation;
693        use serde_json::json;
694
695        // Create an operation with a request body
696        let operation = ApiOperation {
697            method: "post".to_string(),
698            path: "/api/users".to_string(),
699            operation: Operation::default(),
700            operation_id: Some("createUser".to_string()),
701        };
702
703        let template = RequestTemplate {
704            operation,
705            path_params: HashMap::new(),
706            query_params: HashMap::new(),
707            headers: HashMap::new(),
708            body: Some(json!({"name": "test"})),
709        };
710
711        let config = K6Config {
712            target_url: "https://api.example.com".to_string(),
713            base_path: None,
714            scenario: LoadScenario::Constant,
715            duration_secs: 30,
716            max_vus: 5,
717            threshold_percentile: "p(95)".to_string(),
718            threshold_ms: 500,
719            max_error_rate: 0.05,
720            auth_header: None,
721            custom_headers: HashMap::new(),
722            skip_tls_verify: true,
723            security_testing_enabled: false,
724        };
725
726        let generator = K6ScriptGenerator::new(config, vec![template]);
727        let script = generator.generate().expect("Should generate script");
728
729        // Verify the script includes TLS skip option for requests with body
730        assert!(
731            script.contains("insecureSkipTLSVerify: true"),
732            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
733        );
734    }
735
736    #[test]
737    fn test_skip_tls_verify_without_body() {
738        use crate::spec_parser::ApiOperation;
739        use openapiv3::Operation;
740
741        // Create an operation without a request body
742        let operation = ApiOperation {
743            method: "get".to_string(),
744            path: "/api/users".to_string(),
745            operation: Operation::default(),
746            operation_id: Some("getUsers".to_string()),
747        };
748
749        let template = RequestTemplate {
750            operation,
751            path_params: HashMap::new(),
752            query_params: HashMap::new(),
753            headers: HashMap::new(),
754            body: None,
755        };
756
757        let config = K6Config {
758            target_url: "https://api.example.com".to_string(),
759            base_path: None,
760            scenario: LoadScenario::Constant,
761            duration_secs: 30,
762            max_vus: 5,
763            threshold_percentile: "p(95)".to_string(),
764            threshold_ms: 500,
765            max_error_rate: 0.05,
766            auth_header: None,
767            custom_headers: HashMap::new(),
768            skip_tls_verify: true,
769            security_testing_enabled: false,
770        };
771
772        let generator = K6ScriptGenerator::new(config, vec![template]);
773        let script = generator.generate().expect("Should generate script");
774
775        // Verify the script includes TLS skip option for requests without body
776        assert!(
777            script.contains("insecureSkipTLSVerify: true"),
778            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
779        );
780    }
781
782    #[test]
783    fn test_no_skip_tls_verify() {
784        use crate::spec_parser::ApiOperation;
785        use openapiv3::Operation;
786
787        // Create an operation
788        let operation = ApiOperation {
789            method: "get".to_string(),
790            path: "/api/users".to_string(),
791            operation: Operation::default(),
792            operation_id: Some("getUsers".to_string()),
793        };
794
795        let template = RequestTemplate {
796            operation,
797            path_params: HashMap::new(),
798            query_params: HashMap::new(),
799            headers: HashMap::new(),
800            body: None,
801        };
802
803        let config = K6Config {
804            target_url: "https://api.example.com".to_string(),
805            base_path: None,
806            scenario: LoadScenario::Constant,
807            duration_secs: 30,
808            max_vus: 5,
809            threshold_percentile: "p(95)".to_string(),
810            threshold_ms: 500,
811            max_error_rate: 0.05,
812            auth_header: None,
813            custom_headers: HashMap::new(),
814            skip_tls_verify: false,
815            security_testing_enabled: false,
816        };
817
818        let generator = K6ScriptGenerator::new(config, vec![template]);
819        let script = generator.generate().expect("Should generate script");
820
821        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
822        assert!(
823            !script.contains("insecureSkipTLSVerify"),
824            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
825        );
826    }
827
828    #[test]
829    fn test_skip_tls_verify_multiple_operations() {
830        use crate::spec_parser::ApiOperation;
831        use openapiv3::Operation;
832        use serde_json::json;
833
834        // Create multiple operations - one with body, one without
835        let operation1 = ApiOperation {
836            method: "get".to_string(),
837            path: "/api/users".to_string(),
838            operation: Operation::default(),
839            operation_id: Some("getUsers".to_string()),
840        };
841
842        let operation2 = ApiOperation {
843            method: "post".to_string(),
844            path: "/api/users".to_string(),
845            operation: Operation::default(),
846            operation_id: Some("createUser".to_string()),
847        };
848
849        let template1 = RequestTemplate {
850            operation: operation1,
851            path_params: HashMap::new(),
852            query_params: HashMap::new(),
853            headers: HashMap::new(),
854            body: None,
855        };
856
857        let template2 = RequestTemplate {
858            operation: operation2,
859            path_params: HashMap::new(),
860            query_params: HashMap::new(),
861            headers: HashMap::new(),
862            body: Some(json!({"name": "test"})),
863        };
864
865        let config = K6Config {
866            target_url: "https://api.example.com".to_string(),
867            base_path: None,
868            scenario: LoadScenario::Constant,
869            duration_secs: 30,
870            max_vus: 5,
871            threshold_percentile: "p(95)".to_string(),
872            threshold_ms: 500,
873            max_error_rate: 0.05,
874            auth_header: None,
875            custom_headers: HashMap::new(),
876            skip_tls_verify: true,
877            security_testing_enabled: false,
878        };
879
880        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
881        let script = generator.generate().expect("Should generate script");
882
883        // Verify the script includes TLS skip option ONCE in global options
884        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
885        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
886        assert_eq!(
887            skip_count, 1,
888            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
889        );
890
891        // Verify it appears in the options block, before scenarios
892        let options_start = script.find("export const options = {").expect("Should have options");
893        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
894        let options_prefix = &script[options_start..scenarios_start];
895        assert!(
896            options_prefix.contains("insecureSkipTLSVerify: true"),
897            "insecureSkipTLSVerify should be in global options block"
898        );
899    }
900
901    #[test]
902    fn test_dynamic_params_in_body() {
903        use crate::spec_parser::ApiOperation;
904        use openapiv3::Operation;
905        use serde_json::json;
906
907        // Create an operation with dynamic placeholders in the body
908        let operation = ApiOperation {
909            method: "post".to_string(),
910            path: "/api/resources".to_string(),
911            operation: Operation::default(),
912            operation_id: Some("createResource".to_string()),
913        };
914
915        let template = RequestTemplate {
916            operation,
917            path_params: HashMap::new(),
918            query_params: HashMap::new(),
919            headers: HashMap::new(),
920            body: Some(json!({
921                "name": "load-test-${__VU}",
922                "iteration": "${__ITER}"
923            })),
924        };
925
926        let config = K6Config {
927            target_url: "https://api.example.com".to_string(),
928            base_path: None,
929            scenario: LoadScenario::Constant,
930            duration_secs: 30,
931            max_vus: 5,
932            threshold_percentile: "p(95)".to_string(),
933            threshold_ms: 500,
934            max_error_rate: 0.05,
935            auth_header: None,
936            custom_headers: HashMap::new(),
937            skip_tls_verify: false,
938            security_testing_enabled: false,
939        };
940
941        let generator = K6ScriptGenerator::new(config, vec![template]);
942        let script = generator.generate().expect("Should generate script");
943
944        // Verify the script contains dynamic body indication
945        assert!(
946            script.contains("Dynamic body with runtime placeholders"),
947            "Script should contain comment about dynamic body"
948        );
949
950        // Verify the script contains the __VU variable reference
951        assert!(
952            script.contains("__VU"),
953            "Script should contain __VU reference for dynamic VU-based values"
954        );
955
956        // Verify the script contains the __ITER variable reference
957        assert!(
958            script.contains("__ITER"),
959            "Script should contain __ITER reference for dynamic iteration values"
960        );
961    }
962
963    #[test]
964    fn test_dynamic_params_with_uuid() {
965        use crate::spec_parser::ApiOperation;
966        use openapiv3::Operation;
967        use serde_json::json;
968
969        // Create an operation with UUID placeholder
970        let operation = ApiOperation {
971            method: "post".to_string(),
972            path: "/api/resources".to_string(),
973            operation: Operation::default(),
974            operation_id: Some("createResource".to_string()),
975        };
976
977        let template = RequestTemplate {
978            operation,
979            path_params: HashMap::new(),
980            query_params: HashMap::new(),
981            headers: HashMap::new(),
982            body: Some(json!({
983                "id": "${__UUID}"
984            })),
985        };
986
987        let config = K6Config {
988            target_url: "https://api.example.com".to_string(),
989            base_path: None,
990            scenario: LoadScenario::Constant,
991            duration_secs: 30,
992            max_vus: 5,
993            threshold_percentile: "p(95)".to_string(),
994            threshold_ms: 500,
995            max_error_rate: 0.05,
996            auth_header: None,
997            custom_headers: HashMap::new(),
998            skip_tls_verify: false,
999            security_testing_enabled: false,
1000        };
1001
1002        let generator = K6ScriptGenerator::new(config, vec![template]);
1003        let script = generator.generate().expect("Should generate script");
1004
1005        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
1006        // Verify the script does NOT include the old experimental webcrypto import
1007        assert!(
1008            !script.contains("k6/experimental/webcrypto"),
1009            "Script should NOT include deprecated k6/experimental/webcrypto import"
1010        );
1011
1012        // Verify crypto.randomUUID() is in the generated code
1013        assert!(
1014            script.contains("crypto.randomUUID()"),
1015            "Script should contain crypto.randomUUID() for UUID placeholder"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_dynamic_params_with_counter() {
1021        use crate::spec_parser::ApiOperation;
1022        use openapiv3::Operation;
1023        use serde_json::json;
1024
1025        // Create an operation with COUNTER placeholder
1026        let operation = ApiOperation {
1027            method: "post".to_string(),
1028            path: "/api/resources".to_string(),
1029            operation: Operation::default(),
1030            operation_id: Some("createResource".to_string()),
1031        };
1032
1033        let template = RequestTemplate {
1034            operation,
1035            path_params: HashMap::new(),
1036            query_params: HashMap::new(),
1037            headers: HashMap::new(),
1038            body: Some(json!({
1039                "sequence": "${__COUNTER}"
1040            })),
1041        };
1042
1043        let config = K6Config {
1044            target_url: "https://api.example.com".to_string(),
1045            base_path: None,
1046            scenario: LoadScenario::Constant,
1047            duration_secs: 30,
1048            max_vus: 5,
1049            threshold_percentile: "p(95)".to_string(),
1050            threshold_ms: 500,
1051            max_error_rate: 0.05,
1052            auth_header: None,
1053            custom_headers: HashMap::new(),
1054            skip_tls_verify: false,
1055            security_testing_enabled: false,
1056        };
1057
1058        let generator = K6ScriptGenerator::new(config, vec![template]);
1059        let script = generator.generate().expect("Should generate script");
1060
1061        // Verify the script includes the global counter initialization
1062        assert!(
1063            script.contains("let globalCounter = 0"),
1064            "Script should include globalCounter initialization when COUNTER placeholder is used"
1065        );
1066
1067        // Verify globalCounter++ is in the generated code
1068        assert!(
1069            script.contains("globalCounter++"),
1070            "Script should contain globalCounter++ for COUNTER placeholder"
1071        );
1072    }
1073
1074    #[test]
1075    fn test_static_body_no_dynamic_marker() {
1076        use crate::spec_parser::ApiOperation;
1077        use openapiv3::Operation;
1078        use serde_json::json;
1079
1080        // Create an operation with static body (no placeholders)
1081        let operation = ApiOperation {
1082            method: "post".to_string(),
1083            path: "/api/resources".to_string(),
1084            operation: Operation::default(),
1085            operation_id: Some("createResource".to_string()),
1086        };
1087
1088        let template = RequestTemplate {
1089            operation,
1090            path_params: HashMap::new(),
1091            query_params: HashMap::new(),
1092            headers: HashMap::new(),
1093            body: Some(json!({
1094                "name": "static-value",
1095                "count": 42
1096            })),
1097        };
1098
1099        let config = K6Config {
1100            target_url: "https://api.example.com".to_string(),
1101            base_path: None,
1102            scenario: LoadScenario::Constant,
1103            duration_secs: 30,
1104            max_vus: 5,
1105            threshold_percentile: "p(95)".to_string(),
1106            threshold_ms: 500,
1107            max_error_rate: 0.05,
1108            auth_header: None,
1109            custom_headers: HashMap::new(),
1110            skip_tls_verify: false,
1111            security_testing_enabled: false,
1112        };
1113
1114        let generator = K6ScriptGenerator::new(config, vec![template]);
1115        let script = generator.generate().expect("Should generate script");
1116
1117        // Verify the script does NOT contain dynamic body marker
1118        assert!(
1119            !script.contains("Dynamic body with runtime placeholders"),
1120            "Script should NOT contain dynamic body comment for static body"
1121        );
1122
1123        // Verify it does NOT include unnecessary crypto imports
1124        assert!(
1125            !script.contains("webcrypto"),
1126            "Script should NOT include webcrypto import for static body"
1127        );
1128
1129        // Verify it does NOT include global counter
1130        assert!(
1131            !script.contains("let globalCounter"),
1132            "Script should NOT include globalCounter for static body"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_security_testing_enabled_generates_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: true,
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 generated (not just function definitions)
1176        assert!(
1177            script.contains("getNextSecurityPayload"),
1178            "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
1179        );
1180        assert!(
1181            script.contains("applySecurityPayload"),
1182            "Script should contain applySecurityPayload() call when security_testing_enabled is true"
1183        );
1184        assert!(
1185            script.contains("secPayloadGroup"),
1186            "Script should contain secPayloadGroup variable when security_testing_enabled is true"
1187        );
1188        assert!(
1189            script.contains("secBodyPayload"),
1190            "Script should contain secBodyPayload variable when security_testing_enabled is true"
1191        );
1192        // Verify CookieJar skip when Cookie header payload is present
1193        assert!(
1194            script.contains("hasSecCookie"),
1195            "Script should track hasSecCookie for CookieJar conflict avoidance"
1196        );
1197        assert!(
1198            script.contains("secRequestOpts"),
1199            "Script should use secRequestOpts to conditionally skip CookieJar"
1200        );
1201        // Verify mutable headers copy for injection
1202        assert!(
1203            script.contains("const requestHeaders = { ..."),
1204            "Script should spread headers into mutable copy for security payload injection"
1205        );
1206        // Verify injectAsPath handling for path-based URI injection
1207        assert!(
1208            script.contains("secPayload.injectAsPath"),
1209            "Script should check injectAsPath for path-based URI injection"
1210        );
1211        // Verify formBody handling for form-encoded body delivery
1212        assert!(
1213            script.contains("secBodyPayload.formBody"),
1214            "Script should check formBody for form-encoded body delivery"
1215        );
1216        assert!(
1217            script.contains("application/x-www-form-urlencoded"),
1218            "Script should set Content-Type for form-encoded body"
1219        );
1220        // Verify secPayloadGroup is fetched per-operation (inside operation block), not per-iteration
1221        let op_comment_pos =
1222            script.find("// Operation 0:").expect("Should have Operation 0 comment");
1223        let sec_payload_pos = script
1224            .find("const secPayloadGroup = typeof getNextSecurityPayload")
1225            .expect("Should have secPayloadGroup assignment");
1226        assert!(
1227            sec_payload_pos > op_comment_pos,
1228            "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
1229        );
1230    }
1231
1232    #[test]
1233    fn test_security_testing_disabled_no_calling_code() {
1234        use crate::spec_parser::ApiOperation;
1235        use openapiv3::Operation;
1236        use serde_json::json;
1237
1238        let operation = ApiOperation {
1239            method: "post".to_string(),
1240            path: "/api/users".to_string(),
1241            operation: Operation::default(),
1242            operation_id: Some("createUser".to_string()),
1243        };
1244
1245        let template = RequestTemplate {
1246            operation,
1247            path_params: HashMap::new(),
1248            query_params: HashMap::new(),
1249            headers: HashMap::new(),
1250            body: Some(json!({"name": "test"})),
1251        };
1252
1253        let config = K6Config {
1254            target_url: "https://api.example.com".to_string(),
1255            base_path: None,
1256            scenario: LoadScenario::Constant,
1257            duration_secs: 30,
1258            max_vus: 5,
1259            threshold_percentile: "p(95)".to_string(),
1260            threshold_ms: 500,
1261            max_error_rate: 0.05,
1262            auth_header: None,
1263            custom_headers: HashMap::new(),
1264            skip_tls_verify: false,
1265            security_testing_enabled: false,
1266        };
1267
1268        let generator = K6ScriptGenerator::new(config, vec![template]);
1269        let script = generator.generate().expect("Should generate script");
1270
1271        // Verify calling code is NOT generated
1272        assert!(
1273            !script.contains("getNextSecurityPayload"),
1274            "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
1275        );
1276        assert!(
1277            !script.contains("applySecurityPayload"),
1278            "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
1279        );
1280        assert!(
1281            !script.contains("secPayloadGroup"),
1282            "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
1283        );
1284        assert!(
1285            !script.contains("secBodyPayload"),
1286            "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
1287        );
1288        assert!(
1289            !script.contains("hasSecCookie"),
1290            "Script should NOT contain hasSecCookie when security_testing_enabled is false"
1291        );
1292        assert!(
1293            !script.contains("secRequestOpts"),
1294            "Script should NOT contain secRequestOpts when security_testing_enabled is false"
1295        );
1296        assert!(
1297            !script.contains("injectAsPath"),
1298            "Script should NOT contain injectAsPath when security_testing_enabled is false"
1299        );
1300        assert!(
1301            !script.contains("formBody"),
1302            "Script should NOT contain formBody when security_testing_enabled is false"
1303        );
1304    }
1305
1306    /// End-to-end test: simulates the real pipeline of template rendering + enhanced script
1307    /// injection. This is what actually runs when a user passes `--security-test`.
1308    /// Verifies that the FINAL script has both function definitions AND calling code.
1309    #[test]
1310    fn test_security_e2e_definitions_and_calls_both_present() {
1311        use crate::security_payloads::{
1312            SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
1313        };
1314        use crate::spec_parser::ApiOperation;
1315        use openapiv3::Operation;
1316        use serde_json::json;
1317
1318        // Step 1: Generate base script with security_testing_enabled=true (template renders calling code)
1319        let operation = ApiOperation {
1320            method: "post".to_string(),
1321            path: "/api/users".to_string(),
1322            operation: Operation::default(),
1323            operation_id: Some("createUser".to_string()),
1324        };
1325
1326        let template = RequestTemplate {
1327            operation,
1328            path_params: HashMap::new(),
1329            query_params: HashMap::new(),
1330            headers: HashMap::new(),
1331            body: Some(json!({"name": "test"})),
1332        };
1333
1334        let config = K6Config {
1335            target_url: "https://api.example.com".to_string(),
1336            base_path: None,
1337            scenario: LoadScenario::Constant,
1338            duration_secs: 30,
1339            max_vus: 5,
1340            threshold_percentile: "p(95)".to_string(),
1341            threshold_ms: 500,
1342            max_error_rate: 0.05,
1343            auth_header: None,
1344            custom_headers: HashMap::new(),
1345            skip_tls_verify: false,
1346            security_testing_enabled: true,
1347        };
1348
1349        let generator = K6ScriptGenerator::new(config, vec![template]);
1350        let mut script = generator.generate().expect("Should generate base script");
1351
1352        // Step 2: Simulate what generate_enhanced_script() does — inject function definitions
1353        let security_config = SecurityTestConfig::default().enable();
1354        let payloads = SecurityPayloads::get_payloads(&security_config);
1355        assert!(!payloads.is_empty(), "Should have built-in payloads");
1356
1357        let mut additional_code = String::new();
1358        additional_code
1359            .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
1360        additional_code.push('\n');
1361        additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
1362        additional_code.push('\n');
1363
1364        // Insert definitions before 'export const options' (same as generate_enhanced_script)
1365        if let Some(pos) = script.find("export const options") {
1366            script.insert_str(
1367                pos,
1368                &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
1369            );
1370        }
1371
1372        // Step 3: Verify the FINAL script has BOTH definitions AND calls
1373        // Function definitions (injected by generate_enhanced_script)
1374        assert!(
1375            script.contains("function getNextSecurityPayload()"),
1376            "Final script must contain getNextSecurityPayload function DEFINITION"
1377        );
1378        assert!(
1379            script.contains("function applySecurityPayload("),
1380            "Final script must contain applySecurityPayload function DEFINITION"
1381        );
1382        assert!(
1383            script.contains("securityPayloads"),
1384            "Final script must contain securityPayloads array"
1385        );
1386
1387        // Calling code (rendered by template)
1388        assert!(
1389            script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
1390            "Final script must contain secPayloadGroup assignment (template calling code)"
1391        );
1392        assert!(
1393            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1394            "Final script must contain applySecurityPayload CALL with secBodyPayload"
1395        );
1396        assert!(
1397            script.contains("const requestHeaders = { ..."),
1398            "Final script must spread headers for security payload header injection"
1399        );
1400        assert!(
1401            script.contains("for (const secPayload of secPayloadGroup)"),
1402            "Final script must loop over secPayloadGroup"
1403        );
1404        assert!(
1405            script.contains("secPayload.injectAsPath"),
1406            "Final script must check injectAsPath for path-based URI injection"
1407        );
1408        assert!(
1409            script.contains("secBodyPayload.formBody"),
1410            "Final script must check formBody for form-encoded body delivery"
1411        );
1412
1413        // Verify ordering: definitions come BEFORE export default function (which has the calls)
1414        let def_pos = script.find("function getNextSecurityPayload()").unwrap();
1415        let call_pos =
1416            script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
1417        let options_pos = script.find("export const options").unwrap();
1418        let default_fn_pos = script.find("export default function").unwrap();
1419
1420        assert!(
1421            def_pos < options_pos,
1422            "Function definitions must appear before export const options"
1423        );
1424        assert!(
1425            call_pos > default_fn_pos,
1426            "Calling code must appear inside export default function"
1427        );
1428    }
1429
1430    /// Test that URI security payload injection is generated for GET requests
1431    #[test]
1432    fn test_security_uri_injection_for_get_requests() {
1433        use crate::spec_parser::ApiOperation;
1434        use openapiv3::Operation;
1435
1436        let operation = ApiOperation {
1437            method: "get".to_string(),
1438            path: "/api/users".to_string(),
1439            operation: Operation::default(),
1440            operation_id: Some("listUsers".to_string()),
1441        };
1442
1443        let template = RequestTemplate {
1444            operation,
1445            path_params: HashMap::new(),
1446            query_params: HashMap::new(),
1447            headers: HashMap::new(),
1448            body: None,
1449        };
1450
1451        let config = K6Config {
1452            target_url: "https://api.example.com".to_string(),
1453            base_path: None,
1454            scenario: LoadScenario::Constant,
1455            duration_secs: 30,
1456            max_vus: 5,
1457            threshold_percentile: "p(95)".to_string(),
1458            threshold_ms: 500,
1459            max_error_rate: 0.05,
1460            auth_header: None,
1461            custom_headers: HashMap::new(),
1462            skip_tls_verify: false,
1463            security_testing_enabled: true,
1464        };
1465
1466        let generator = K6ScriptGenerator::new(config, vec![template]);
1467        let script = generator.generate().expect("Should generate script");
1468
1469        // Verify URI injection code is present for GET requests
1470        assert!(
1471            script.contains("requestUrl"),
1472            "Script should build requestUrl variable for URI payload injection"
1473        );
1474        assert!(
1475            script.contains("secPayload.location === 'uri'"),
1476            "Script should check for URI-location payloads"
1477        );
1478        // URI payloads are URL-encoded for valid HTTP; WAF decodes before inspection
1479        assert!(
1480            script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
1481            "Script should URL-encode security payload in query string for valid HTTP"
1482        );
1483        // Verify injectAsPath check for path-based injection
1484        assert!(
1485            script.contains("secPayload.injectAsPath"),
1486            "Script should check injectAsPath for path-based URI injection"
1487        );
1488        assert!(
1489            script.contains("encodeURI(secPayload.payload)"),
1490            "Script should use encodeURI for path-based injection"
1491        );
1492        // Verify the GET request uses requestUrl
1493        assert!(
1494            script.contains("http.get(requestUrl,"),
1495            "GET request should use requestUrl (with URI injection) instead of inline URL"
1496        );
1497    }
1498
1499    /// Test that URI security payload injection is generated for POST requests with body
1500    #[test]
1501    fn test_security_uri_injection_for_post_requests() {
1502        use crate::spec_parser::ApiOperation;
1503        use openapiv3::Operation;
1504        use serde_json::json;
1505
1506        let operation = ApiOperation {
1507            method: "post".to_string(),
1508            path: "/api/users".to_string(),
1509            operation: Operation::default(),
1510            operation_id: Some("createUser".to_string()),
1511        };
1512
1513        let template = RequestTemplate {
1514            operation,
1515            path_params: HashMap::new(),
1516            query_params: HashMap::new(),
1517            headers: HashMap::new(),
1518            body: Some(json!({"name": "test"})),
1519        };
1520
1521        let config = K6Config {
1522            target_url: "https://api.example.com".to_string(),
1523            base_path: None,
1524            scenario: LoadScenario::Constant,
1525            duration_secs: 30,
1526            max_vus: 5,
1527            threshold_percentile: "p(95)".to_string(),
1528            threshold_ms: 500,
1529            max_error_rate: 0.05,
1530            auth_header: None,
1531            custom_headers: HashMap::new(),
1532            skip_tls_verify: false,
1533            security_testing_enabled: true,
1534        };
1535
1536        let generator = K6ScriptGenerator::new(config, vec![template]);
1537        let script = generator.generate().expect("Should generate script");
1538
1539        // POST with body should get BOTH URI injection AND body injection
1540        assert!(
1541            script.contains("requestUrl"),
1542            "POST script should build requestUrl for URI payload injection"
1543        );
1544        assert!(
1545            script.contains("secPayload.location === 'uri'"),
1546            "POST script should check for URI-location payloads"
1547        );
1548        assert!(
1549            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
1550            "POST script should apply security body payload to request body"
1551        );
1552        // Verify the POST request uses requestUrl
1553        assert!(
1554            script.contains("http.post(requestUrl,"),
1555            "POST request should use requestUrl (with URI injection) instead of inline URL"
1556        );
1557    }
1558
1559    /// Test that security is disabled - no URI injection code present
1560    #[test]
1561    fn test_no_uri_injection_when_security_disabled() {
1562        use crate::spec_parser::ApiOperation;
1563        use openapiv3::Operation;
1564
1565        let operation = ApiOperation {
1566            method: "get".to_string(),
1567            path: "/api/users".to_string(),
1568            operation: Operation::default(),
1569            operation_id: Some("listUsers".to_string()),
1570        };
1571
1572        let template = RequestTemplate {
1573            operation,
1574            path_params: HashMap::new(),
1575            query_params: HashMap::new(),
1576            headers: HashMap::new(),
1577            body: None,
1578        };
1579
1580        let config = K6Config {
1581            target_url: "https://api.example.com".to_string(),
1582            base_path: None,
1583            scenario: LoadScenario::Constant,
1584            duration_secs: 30,
1585            max_vus: 5,
1586            threshold_percentile: "p(95)".to_string(),
1587            threshold_ms: 500,
1588            max_error_rate: 0.05,
1589            auth_header: None,
1590            custom_headers: HashMap::new(),
1591            skip_tls_verify: false,
1592            security_testing_enabled: false,
1593        };
1594
1595        let generator = K6ScriptGenerator::new(config, vec![template]);
1596        let script = generator.generate().expect("Should generate script");
1597
1598        // Verify NO security injection code when disabled
1599        assert!(
1600            !script.contains("requestUrl"),
1601            "Script should NOT have requestUrl when security is disabled"
1602        );
1603        assert!(
1604            !script.contains("secPayloadGroup"),
1605            "Script should NOT have secPayloadGroup when security is disabled"
1606        );
1607        assert!(
1608            !script.contains("secBodyPayload"),
1609            "Script should NOT have secBodyPayload when security is disabled"
1610        );
1611    }
1612
1613    /// Test that scripts create a fresh CookieJar per request (not a shared constant)
1614    #[test]
1615    fn test_uses_per_request_cookie_jar() {
1616        use crate::spec_parser::ApiOperation;
1617        use openapiv3::Operation;
1618
1619        let operation = ApiOperation {
1620            method: "get".to_string(),
1621            path: "/api/users".to_string(),
1622            operation: Operation::default(),
1623            operation_id: Some("listUsers".to_string()),
1624        };
1625
1626        let template = RequestTemplate {
1627            operation,
1628            path_params: HashMap::new(),
1629            query_params: HashMap::new(),
1630            headers: HashMap::new(),
1631            body: None,
1632        };
1633
1634        let config = K6Config {
1635            target_url: "https://api.example.com".to_string(),
1636            base_path: None,
1637            scenario: LoadScenario::Constant,
1638            duration_secs: 30,
1639            max_vus: 5,
1640            threshold_percentile: "p(95)".to_string(),
1641            threshold_ms: 500,
1642            max_error_rate: 0.05,
1643            auth_header: None,
1644            custom_headers: HashMap::new(),
1645            skip_tls_verify: false,
1646            security_testing_enabled: false,
1647        };
1648
1649        let generator = K6ScriptGenerator::new(config, vec![template]);
1650        let script = generator.generate().expect("Should generate script");
1651
1652        // Each request must create a fresh CookieJar to prevent Set-Cookie accumulation
1653        assert!(
1654            script.contains("jar: new http.CookieJar()"),
1655            "Script should create fresh CookieJar per request"
1656        );
1657        assert!(
1658            !script.contains("jar: null"),
1659            "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
1660        );
1661        assert!(
1662            !script.contains("EMPTY_JAR"),
1663            "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
1664        );
1665    }
1666}