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