Skip to main content

mockforge_bench/
k6_gen.rs

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