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