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