Skip to main content

mockforge_bench/
k6_gen.rs

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