Skip to main content

mockforge_bench/
k6_gen.rs

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