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