Skip to main content

mockforge_bench/
k6_gen.rs

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