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