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