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    let joined = join_base_path_raw(base_path, raw_path);
864    // Always a path: a joined value without a leading '/' (e.g. a base path
865    // of "@host:port") would become part of the URL authority once appended
866    // to the target URL.
867    if joined.starts_with('/') {
868        joined
869    } else {
870        format!("/{joined}")
871    }
872}
873
874fn join_base_path_raw(base_path: &str, raw_path: &str) -> String {
875    match base_path {
876        "" | "/" => raw_path.to_string(),
877        bp => {
878            let bp = bp.trim_end_matches('/');
879            if raw_path.starts_with('/') {
880                format!("{}{}", bp, raw_path)
881            } else {
882                format!("{}/{}", bp, raw_path)
883            }
884        }
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    #[test]
893    fn root_base_path_does_not_double_slash() {
894        assert_eq!(
895            join_base_path(
896                "/",
897                "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
898            ),
899            "/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
900        );
901        assert_eq!(join_base_path("", "/pets"), "/pets");
902        assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
903        assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
904        assert_eq!(join_base_path("@localhost:6565", "/pets"), "/@localhost:6565/pets");
905        assert_eq!(join_base_path("", "pets"), "/pets");
906    }
907
908    #[test]
909    fn test_k6_config_creation() {
910        let config = K6Config {
911            target_url: "https://api.example.com".to_string(),
912            base_path: None,
913            scenario: LoadScenario::RampUp,
914            duration_secs: 60,
915            max_vus: 10,
916            threshold_percentile: "p(95)".to_string(),
917            threshold_ms: 500,
918            max_error_rate: 0.05,
919            auth_header: None,
920            custom_headers: HashMap::new(),
921            skip_tls_verify: false,
922            security_testing_enabled: false,
923            chunked_request_bodies: false,
924            target_rps: None,
925            no_keep_alive: false,
926            geo_source_ips: Vec::new(),
927            geo_source_headers: Vec::new(),
928        };
929
930        assert_eq!(config.duration_secs, 60);
931        assert_eq!(config.max_vus, 10);
932    }
933
934    #[test]
935    fn test_script_generator_creation() {
936        let config = K6Config {
937            target_url: "https://api.example.com".to_string(),
938            base_path: None,
939            scenario: LoadScenario::Constant,
940            duration_secs: 30,
941            max_vus: 5,
942            threshold_percentile: "p(95)".to_string(),
943            threshold_ms: 500,
944            max_error_rate: 0.05,
945            auth_header: None,
946            custom_headers: HashMap::new(),
947            skip_tls_verify: false,
948            security_testing_enabled: false,
949            chunked_request_bodies: false,
950            target_rps: None,
951            no_keep_alive: false,
952            geo_source_ips: Vec::new(),
953            geo_source_headers: Vec::new(),
954        };
955
956        let templates = vec![];
957        let generator = K6ScriptGenerator::new(config, templates);
958
959        assert_eq!(generator.templates.len(), 0);
960    }
961
962    #[test]
963    fn colliding_operation_titles_get_unique_const_names() {
964        // #79 (g): two YAML cases titled "normal request allowed" used to
965        // emit `const normal_request_allowed_latency` twice. k6 then exited
966        // 107 on every target with 0 requests.
967        use crate::spec_parser::ApiOperation;
968        use openapiv3::Operation;
969
970        fn tmpl(id: &str, path: &str) -> RequestTemplate {
971            RequestTemplate {
972                operation: ApiOperation {
973                    method: "get".to_string(),
974                    path: path.to_string(),
975                    operation: Operation::default(),
976                    operation_id: Some(id.to_string()),
977                },
978                path_params: HashMap::new(),
979                query_params: HashMap::new(),
980                headers: HashMap::new(),
981                body: None,
982            }
983        }
984
985        let config = K6Config {
986            target_url: "https://example.test".to_string(),
987            base_path: None,
988            scenario: LoadScenario::Constant,
989            duration_secs: 5,
990            max_vus: 1,
991            threshold_percentile: "p(95)".to_string(),
992            threshold_ms: 500,
993            max_error_rate: 0.05,
994            auth_header: None,
995            custom_headers: HashMap::new(),
996            skip_tls_verify: false,
997            security_testing_enabled: false,
998            chunked_request_bodies: false,
999            target_rps: None,
1000            no_keep_alive: false,
1001            geo_source_ips: Vec::new(),
1002            geo_source_headers: Vec::new(),
1003        };
1004        let generator = K6ScriptGenerator::new(
1005            config,
1006            vec![
1007                tmpl("normal request allowed", "/a"),
1008                tmpl("normal request allowed", "/b"),
1009            ],
1010        );
1011        let script = generator.generate().expect("script generates");
1012        let latency = script
1013            .lines()
1014            .filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
1015            .collect::<Vec<_>>();
1016        assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
1017        assert!(
1018            script.contains("const normal_request_allowed_latency = new Trend"),
1019            "first collision keeps the base name"
1020        );
1021        assert!(
1022            script.contains("const normal_request_allowed_2_latency = new Trend")
1023                || script.contains("const normal_request_allowed_latency_2 = new Trend"),
1024            "second collision must be renamed, script snippet:\n{}",
1025            latency.join("\n")
1026        );
1027        let errors = K6ScriptGenerator::validate_script(&script);
1028        assert!(errors.is_empty(), "validate_script: {errors:#?}");
1029    }
1030
1031    #[test]
1032    fn werkzeug_unc_backslash_x_is_json_encoded_not_template_literal() {
1033        // #79 (g) round 2: werkzeug_cve-2026-48818.yaml uri
1034        // `/static/\\attacker.com\share\x` dumped into a template literal
1035        // made k6/goja exit on `invalid escape: \x: len("") != 2`.
1036        // Static paths are now JSON strings concatenated onto BASE_URL.
1037        use crate::spec_parser::ApiOperation;
1038        use openapiv3::Operation;
1039
1040        let path = "/static/\\\\attacker.com\\share\\x";
1041        let template = RequestTemplate {
1042            operation: ApiOperation {
1043                method: "get".to_string(),
1044                path: path.to_string(),
1045                operation: Operation::default(),
1046                operation_id: Some("literal UNC double-backslash path blocked".to_string()),
1047            },
1048            path_params: HashMap::new(),
1049            query_params: HashMap::new(),
1050            headers: HashMap::new(),
1051            body: None,
1052        };
1053        let config = K6Config {
1054            target_url: "https://example.test".to_string(),
1055            base_path: None,
1056            scenario: LoadScenario::Constant,
1057            duration_secs: 5,
1058            max_vus: 1,
1059            threshold_percentile: "p(95)".to_string(),
1060            threshold_ms: 500,
1061            max_error_rate: 0.05,
1062            auth_header: None,
1063            custom_headers: HashMap::new(),
1064            skip_tls_verify: false,
1065            security_testing_enabled: false,
1066            chunked_request_bodies: false,
1067            target_rps: None,
1068            no_keep_alive: false,
1069            geo_source_ips: Vec::new(),
1070            geo_source_headers: Vec::new(),
1071        };
1072        let script = K6ScriptGenerator::new(config, vec![template])
1073            .generate()
1074            .expect("script generates");
1075        let encoded = serde_json::to_string(path).expect("path JSON");
1076        assert!(
1077            script.contains(&format!("BASE_URL + {encoded}")),
1078            "expected BASE_URL + {encoded} in script:\n{script}"
1079        );
1080        assert!(
1081            !script.contains("${BASE_URL}/static/"),
1082            "must not dump the raw path into a template literal:\n{script}"
1083        );
1084        let errors = K6ScriptGenerator::validate_script(&script);
1085        assert!(errors.is_empty(), "validate_script: {errors:#?}\n{script}");
1086    }
1087
1088    #[test]
1089    fn validate_script_flags_bare_hex_escape_in_template_literal() {
1090        // The 0.3.217 smoking gun, reduced: a template literal with a
1091        // trailing `\x` must fail validation before k6 is spawned.
1092        let bad = r#"
1093import http from 'k6/http';
1094import { check, sleep } from 'k6';
1095import { Rate, Trend } from 'k6/metrics';
1096const t_latency = new Trend('t_latency');
1097export default function() {
1098    const res = http.get(`${BASE_URL}/static/\\attacker.com\share\x`);
1099}
1100"#;
1101        let errors = K6ScriptGenerator::validate_script(bad);
1102        assert!(
1103            errors.iter().any(|e| e.contains("invalid JS hex escape")),
1104            "expected hex-escape error, got {errors:#?}"
1105        );
1106        assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
1107            r#"http.get(`${BASE_URL}/static/\\attacker.com\share\x`)"#
1108        )
1109        .is_some());
1110        assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
1111            r#"BASE_URL + "/static/\\\\attacker.com\\share\\x""#
1112        )
1113        .is_none());
1114    }
1115
1116    #[test]
1117    fn test_sanitize_js_identifier() {
1118        // Test case from issue #79: names with dots
1119        assert_eq!(
1120            K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
1121            "billing_subscriptions_v1"
1122        );
1123
1124        // Test other invalid characters
1125        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
1126
1127        // Test names starting with numbers
1128        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
1129
1130        // Test already valid identifiers
1131        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
1132
1133        // Test with multiple consecutive invalid chars
1134        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
1135
1136        // Test empty string (should return default)
1137        assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
1138
1139        // Test with special characters
1140        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
1141
1142        // Test CRUD flow names with dots (issue #79 follow-up)
1143        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
1144        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
1145        assert_eq!(
1146            K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
1147            "plans_update_pricing_schemes"
1148        );
1149        assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
1150    }
1151
1152    #[test]
1153    fn test_sanitize_k6_metric_name_short_passthrough() {
1154        // Names within the limit should pass through unchanged.
1155        let short = "billing_subscriptions_list";
1156        let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
1157        assert_eq!(out, short);
1158        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
1159    }
1160
1161    #[test]
1162    fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
1163        // Real example from issue #79 (Srikanth's microsoft-graph.yaml run):
1164        // operationId nested deep enough that the sanitized name + `_latency`
1165        // exceeds k6's 128-char limit and gets rejected by validate_script.
1166        let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
1167                    charts.workbookChart.axes.categoryAxis.format.line.clear";
1168        let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
1169
1170        // Base must fit within MAX_LEN, leaving room for `_latency` / `_errors`.
1171        assert!(
1172            metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
1173            "metric base len {} exceeded cap {}",
1174            metric.len(),
1175            K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
1176        );
1177
1178        // Both the bare metric and the suffixed forms must pass k6's validator.
1179        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1180        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
1181        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
1182        // Worst-case suffix used by `k6_crud_flow.hbs`.
1183        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
1184    }
1185
1186    #[test]
1187    fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
1188        // Two long names that share a long common prefix must NOT collide
1189        // after truncation — the trailing hash makes them distinct.
1190        let prefix = "a".repeat(150);
1191        let a = format!("{prefix}.foo");
1192        let b = format!("{prefix}.bar");
1193        let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
1194        let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
1195        assert_ne!(ma, mb, "distinct long names produced the same metric name");
1196    }
1197
1198    #[test]
1199    fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
1200        // Truncation must preserve the "starts with letter or _" k6 rule.
1201        let long = format!("{}123end", "x".repeat(120));
1202        let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
1203        assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
1204    }
1205
1206    #[test]
1207    fn test_microsoft_graph_long_operation_id_passes_validation() {
1208        // End-to-end: an ApiOperation with a microsoft-graph-style long
1209        // operationId must produce a script that passes validate_script.
1210        use crate::spec_parser::ApiOperation;
1211        use openapiv3::Operation;
1212
1213        let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
1214            workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
1215            line.clear";
1216
1217        let operation = ApiOperation {
1218            method: "post".to_string(),
1219            path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
1220            operation: Operation::default(),
1221            operation_id: Some(long_op_id.to_string()),
1222        };
1223        let template = RequestTemplate {
1224            operation,
1225            path_params: HashMap::new(),
1226            query_params: HashMap::new(),
1227            headers: HashMap::new(),
1228            body: None,
1229        };
1230        let config = K6Config {
1231            target_url: "https://api.example.com".to_string(),
1232            base_path: Some("/v1.0".to_string()),
1233            scenario: LoadScenario::Constant,
1234            duration_secs: 30,
1235            max_vus: 5,
1236            threshold_percentile: "p(95)".to_string(),
1237            threshold_ms: 500,
1238            max_error_rate: 0.05,
1239            auth_header: None,
1240            custom_headers: HashMap::new(),
1241            skip_tls_verify: false,
1242            security_testing_enabled: false,
1243            chunked_request_bodies: false,
1244            target_rps: None,
1245            no_keep_alive: false,
1246            geo_source_ips: Vec::new(),
1247            geo_source_headers: Vec::new(),
1248        };
1249        let generator = K6ScriptGenerator::new(config, vec![template]);
1250        let script = generator.generate().expect("script generates");
1251
1252        let errors = K6ScriptGenerator::validate_script(&script);
1253        assert!(
1254            errors.is_empty(),
1255            "validate_script returned errors for long operationId: {errors:#?}"
1256        );
1257    }
1258
1259    /// Round 62 (#79) — the abort-on-error valve must default on (round 60),
1260    /// drop out entirely under `--no-abort-on-error`, and honour a tuned rate.
1261    /// Srikanth's WAF stress runs sat at ~95.2% rejections, just over the
1262    /// hard-coded 0.95, so the valve stopped legitimate stress tests at ~2min.
1263    #[test]
1264    fn test_abort_valve_opt_out_and_rate() {
1265        fn base_config() -> K6Config {
1266            K6Config {
1267                target_url: "https://api.example.com".to_string(),
1268                base_path: None,
1269                scenario: LoadScenario::Constant,
1270                duration_secs: 30,
1271                max_vus: 5,
1272                threshold_percentile: "p(95)".to_string(),
1273                threshold_ms: 500,
1274                max_error_rate: 0.05,
1275                auth_header: None,
1276                custom_headers: HashMap::new(),
1277                skip_tls_verify: false,
1278                security_testing_enabled: false,
1279                chunked_request_bodies: false,
1280                target_rps: None,
1281                no_keep_alive: false,
1282                geo_source_ips: Vec::new(),
1283                geo_source_headers: Vec::new(),
1284            }
1285        }
1286
1287        // Default: valve on at 0.95 (unchanged round-60 behaviour).
1288        let default_script = K6ScriptGenerator::new(base_config(), vec![])
1289            .generate()
1290            .expect("script generates");
1291        assert!(
1292            default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
1293            "default script must keep the 0.95 abort valve"
1294        );
1295
1296        // --no-abort-on-error: the abortOnFail object is omitted entirely so a
1297        // stress run executes its full duration regardless of error rate.
1298        let stress_script = K6ScriptGenerator::new(base_config(), vec![])
1299            .with_abort_valve(false, 0.95)
1300            .generate()
1301            .expect("script generates");
1302        // Match the threshold object (`abortOnFail: true`), not the word alone:
1303        // the template comment legitimately mentions "abortOnFail".
1304        assert!(
1305            !stress_script.contains("abortOnFail: true"),
1306            "--no-abort-on-error must drop the abortOnFail threshold"
1307        );
1308        // The pass/fail threshold still records the failure rate.
1309        assert!(stress_script.contains("rate<0.05"));
1310
1311        // --abort-on-error-rate 0.99: valve on, but only fires above 99%.
1312        let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
1313            .with_abort_valve(true, 0.99)
1314            .generate()
1315            .expect("script generates");
1316        assert!(
1317            tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
1318            "--abort-on-error-rate must retune the valve threshold"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_script_generation_with_dots_in_name() {
1324        use crate::spec_parser::ApiOperation;
1325        use openapiv3::Operation;
1326
1327        // Create an operation with a name containing dots (like in issue #79)
1328        let operation = ApiOperation {
1329            method: "get".to_string(),
1330            path: "/billing/subscriptions".to_string(),
1331            operation: Operation::default(),
1332            operation_id: Some("billing.subscriptions.v1".to_string()),
1333        };
1334
1335        let template = RequestTemplate {
1336            operation,
1337            path_params: HashMap::new(),
1338            query_params: HashMap::new(),
1339            headers: HashMap::new(),
1340            body: None,
1341        };
1342
1343        let config = K6Config {
1344            target_url: "https://api.example.com".to_string(),
1345            base_path: None,
1346            scenario: LoadScenario::Constant,
1347            duration_secs: 30,
1348            max_vus: 5,
1349            threshold_percentile: "p(95)".to_string(),
1350            threshold_ms: 500,
1351            max_error_rate: 0.05,
1352            auth_header: None,
1353            custom_headers: HashMap::new(),
1354            skip_tls_verify: false,
1355            security_testing_enabled: false,
1356            chunked_request_bodies: false,
1357            target_rps: None,
1358            no_keep_alive: false,
1359            geo_source_ips: Vec::new(),
1360            geo_source_headers: Vec::new(),
1361        };
1362
1363        let generator = K6ScriptGenerator::new(config, vec![template]);
1364        let script = generator.generate().expect("Should generate script");
1365
1366        // Verify the script contains sanitized variable names (no dots in variable identifiers)
1367        assert!(
1368            script.contains("const billing_subscriptions_v1_latency"),
1369            "Script should contain sanitized variable name for latency"
1370        );
1371        assert!(
1372            script.contains("const billing_subscriptions_v1_errors"),
1373            "Script should contain sanitized variable name for errors"
1374        );
1375
1376        // Verify variable names do NOT contain dots (check the actual variable identifier, not string literals)
1377        // The pattern "const billing.subscriptions" would indicate a variable name with dots
1378        assert!(
1379            !script.contains("const billing.subscriptions"),
1380            "Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
1381        );
1382
1383        // Verify metric name strings are sanitized (no dots) - k6 requires valid metric names
1384        // Metric names must only contain ASCII letters, numbers, or underscores
1385        assert!(
1386            script.contains("'billing_subscriptions_v1_latency'"),
1387            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1388        );
1389        assert!(
1390            script.contains("'billing_subscriptions_v1_errors'"),
1391            "Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
1392        );
1393
1394        // Verify the original display name is still used in comments and strings (for readability)
1395        assert!(
1396            script.contains("billing.subscriptions.v1"),
1397            "Script should contain original name in comments/strings for readability"
1398        );
1399
1400        // Most importantly: verify the variable usage doesn't have dots
1401        assert!(
1402            script.contains("billing_subscriptions_v1_latency.add"),
1403            "Variable usage should use sanitized name"
1404        );
1405        assert!(
1406            script.contains("billing_subscriptions_v1_errors.add"),
1407            "Variable usage should use sanitized name"
1408        );
1409    }
1410
1411    /// Issue #79 (round 5) regression: `--rps` with the default `ramp-up`
1412    /// scenario produced 0 requests because the script took
1413    /// `preAllocatedVUs` from the *last* stage's target — and ramp-up's last
1414    /// stage is the ramp-DOWN to `target: 0`. The fix is to use the
1415    /// configured `max_vus` directly when `target_rps` is set, and the full
1416    /// `duration_secs` rather than the last stage's duration.
1417    #[test]
1418    fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
1419        use crate::spec_parser::ApiOperation;
1420        use openapiv3::Operation;
1421
1422        let operation = ApiOperation {
1423            method: "get".to_string(),
1424            path: "/users".to_string(),
1425            operation: Operation::default(),
1426            operation_id: Some("listUsers".to_string()),
1427        };
1428        let template = RequestTemplate {
1429            operation,
1430            path_params: HashMap::new(),
1431            query_params: HashMap::new(),
1432            headers: HashMap::new(),
1433            body: None,
1434        };
1435
1436        let config = K6Config {
1437            target_url: "https://api.example.com".to_string(),
1438            base_path: None,
1439            scenario: LoadScenario::RampUp,
1440            duration_secs: 600,
1441            max_vus: 100,
1442            threshold_percentile: "p(95)".to_string(),
1443            threshold_ms: 500,
1444            max_error_rate: 0.05,
1445            auth_header: None,
1446            custom_headers: HashMap::new(),
1447            skip_tls_verify: false,
1448            security_testing_enabled: false,
1449            chunked_request_bodies: false,
1450            target_rps: Some(100),
1451            no_keep_alive: false,
1452            geo_source_ips: Vec::new(),
1453            geo_source_headers: Vec::new(),
1454        };
1455
1456        let generator = K6ScriptGenerator::new(config, vec![template]);
1457        let script = generator.generate().expect("Should generate script");
1458
1459        assert!(
1460            script.contains("constant-arrival-rate"),
1461            "with --rps set, executor must switch to constant-arrival-rate"
1462        );
1463        assert!(
1464            script.contains("rate: 100,"),
1465            "constant-arrival-rate must use the configured --rps as `rate`"
1466        );
1467        assert!(
1468            script.contains("duration: '600s'"),
1469            "duration must come from --duration, not the ramp-down stage; got:\n{}",
1470            script
1471        );
1472        assert!(
1473            script.contains("preAllocatedVUs: 100,"),
1474            "preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
1475            script
1476        );
1477        assert!(
1478            script.contains("maxVUs: 100,"),
1479            "maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
1480            script
1481        );
1482        // Make sure the regression — `preAllocatedVUs: 0` from the ramp-down —
1483        // can never silently come back. Walk the lines so we don't false-
1484        // positive on the explanatory comment that lives in the template.
1485        for (idx, line) in script.lines().enumerate() {
1486            let trimmed = line.trim_start();
1487            if trimmed.starts_with("//") || trimmed.starts_with("/*") {
1488                continue;
1489            }
1490            assert!(
1491                !trimmed.starts_with("preAllocatedVUs: 0"),
1492                "regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
1493                 will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
1494                idx + 1,
1495                line,
1496            );
1497        }
1498    }
1499
1500    /// Companion to the test above: confirm `--cps` flips `noConnectionReuse`
1501    /// on. Issue #79 (round 5).
1502    #[test]
1503    fn test_cps_sets_no_connection_reuse() {
1504        use crate::spec_parser::ApiOperation;
1505        use openapiv3::Operation;
1506
1507        let operation = ApiOperation {
1508            method: "get".to_string(),
1509            path: "/u".to_string(),
1510            operation: Operation::default(),
1511            operation_id: Some("u".to_string()),
1512        };
1513        let template = RequestTemplate {
1514            operation,
1515            path_params: HashMap::new(),
1516            query_params: HashMap::new(),
1517            headers: HashMap::new(),
1518            body: None,
1519        };
1520        let config = K6Config {
1521            target_url: "https://api.example.com".to_string(),
1522            base_path: None,
1523            scenario: LoadScenario::Constant,
1524            duration_secs: 30,
1525            max_vus: 5,
1526            threshold_percentile: "p(95)".to_string(),
1527            threshold_ms: 500,
1528            max_error_rate: 0.05,
1529            auth_header: None,
1530            custom_headers: HashMap::new(),
1531            skip_tls_verify: false,
1532            security_testing_enabled: false,
1533            chunked_request_bodies: false,
1534            target_rps: None,
1535            no_keep_alive: true,
1536            geo_source_ips: Vec::new(),
1537            geo_source_headers: Vec::new(),
1538        };
1539        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1540        assert!(
1541            script.contains("noConnectionReuse: true"),
1542            "--cps must set noConnectionReuse: true on the k6 options block"
1543        );
1544        assert!(
1545            script.contains("Total Connections:"),
1546            "--cps summary must include connection-rate output (Srikanth's round-5 ask)"
1547        );
1548        assert!(
1549            script.contains("Connection Rate:"),
1550            "--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
1551        );
1552    }
1553
1554    /// Issue #79 round 6 follow-up: Srikanth reported `--vus 5 -d 600s` taking
1555    /// until the 6-minute mark to reach 5 VUs because the script always set
1556    /// `startVUs: 0`, so k6's `ramping-vus` linearly ramped 0 → 5 over the
1557    /// whole window. For `--scenario constant` we now seed startVUs at the
1558    /// target so the test runs at full concurrency from t=0.
1559    #[test]
1560    fn test_constant_scenario_starts_at_target_vus() {
1561        use crate::spec_parser::ApiOperation;
1562        use openapiv3::Operation;
1563
1564        let operation = ApiOperation {
1565            method: "get".to_string(),
1566            path: "/u".to_string(),
1567            operation: Operation::default(),
1568            operation_id: Some("u".to_string()),
1569        };
1570        let template = RequestTemplate {
1571            operation,
1572            path_params: HashMap::new(),
1573            query_params: HashMap::new(),
1574            headers: HashMap::new(),
1575            body: None,
1576        };
1577        let config = K6Config {
1578            target_url: "https://api.example.com".to_string(),
1579            base_path: None,
1580            scenario: LoadScenario::Constant,
1581            duration_secs: 600,
1582            max_vus: 5,
1583            threshold_percentile: "p(95)".to_string(),
1584            threshold_ms: 500,
1585            max_error_rate: 0.05,
1586            auth_header: None,
1587            custom_headers: HashMap::new(),
1588            skip_tls_verify: false,
1589            security_testing_enabled: false,
1590            chunked_request_bodies: false,
1591            target_rps: None,
1592            no_keep_alive: false,
1593            geo_source_ips: Vec::new(),
1594            geo_source_headers: Vec::new(),
1595        };
1596        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1597        assert!(
1598            script.contains("startVUs: 5,"),
1599            "--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
1600            script
1601        );
1602        // RampUp should still start at 0 — confirm we didn't break ramps.
1603        let ramp_config = K6Config {
1604            target_url: "https://api.example.com".to_string(),
1605            base_path: None,
1606            scenario: LoadScenario::RampUp,
1607            duration_secs: 600,
1608            max_vus: 5,
1609            threshold_percentile: "p(95)".to_string(),
1610            threshold_ms: 500,
1611            max_error_rate: 0.05,
1612            auth_header: None,
1613            custom_headers: HashMap::new(),
1614            skip_tls_verify: false,
1615            security_testing_enabled: false,
1616            chunked_request_bodies: false,
1617            target_rps: None,
1618            no_keep_alive: false,
1619            geo_source_ips: Vec::new(),
1620            geo_source_headers: Vec::new(),
1621        };
1622        let ramp_template = RequestTemplate {
1623            operation: ApiOperation {
1624                method: "get".to_string(),
1625                path: "/u".to_string(),
1626                operation: Operation::default(),
1627                operation_id: Some("u".to_string()),
1628            },
1629            path_params: HashMap::new(),
1630            query_params: HashMap::new(),
1631            headers: HashMap::new(),
1632            body: None,
1633        };
1634        let ramp_script =
1635            K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
1636        assert!(
1637            ramp_script.contains("startVUs: 0,"),
1638            "--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
1639            ramp_script
1640        );
1641    }
1642
1643    /// Issue #79 round 6 follow-up: srikr's `--rps 100 --vus 5` summary showed
1644    /// no "Connections opened" line because the client-side connection counter
1645    /// was reading `http_req_connecting.values.count` — a field that doesn't
1646    /// exist (k6's Trend metric only emits avg/min/med/max/p90/p95). The fix
1647    /// adds a dedicated Counter (`mockforge_connections_opened`) that the
1648    /// template increments whenever `res.timings.connecting > 0`. This test
1649    /// guards both the metric declaration and the per-request increment so
1650    /// the connection counter can't silently regress.
1651    #[test]
1652    fn test_connections_opened_counter_present() {
1653        use crate::spec_parser::ApiOperation;
1654        use openapiv3::Operation;
1655
1656        let operation = ApiOperation {
1657            method: "get".to_string(),
1658            path: "/u".to_string(),
1659            operation: Operation::default(),
1660            operation_id: Some("u".to_string()),
1661        };
1662        let template = RequestTemplate {
1663            operation,
1664            path_params: HashMap::new(),
1665            query_params: HashMap::new(),
1666            headers: HashMap::new(),
1667            body: None,
1668        };
1669        let config = K6Config {
1670            target_url: "https://api.example.com".to_string(),
1671            base_path: None,
1672            scenario: LoadScenario::Constant,
1673            duration_secs: 30,
1674            max_vus: 5,
1675            threshold_percentile: "p(95)".to_string(),
1676            threshold_ms: 500,
1677            max_error_rate: 0.05,
1678            auth_header: None,
1679            custom_headers: HashMap::new(),
1680            skip_tls_verify: false,
1681            security_testing_enabled: false,
1682            chunked_request_bodies: false,
1683            target_rps: Some(50),
1684            no_keep_alive: false,
1685            geo_source_ips: Vec::new(),
1686            geo_source_headers: Vec::new(),
1687        };
1688        let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
1689        assert!(
1690            script.contains("new Counter('mockforge_connections_opened')"),
1691            "template must declare the mockforge_connections_opened Counter"
1692        );
1693        assert!(
1694            script.contains("mockforge_connections_opened.add(1)"),
1695            "template must increment mockforge_connections_opened on new TCP connect"
1696        );
1697        assert!(
1698            script.contains("res.timings.connecting > 0"),
1699            "template must gate the connection-opened increment on \
1700             res.timings.connecting > 0 (only fires when a fresh socket was opened)"
1701        );
1702    }
1703
1704    #[test]
1705    fn test_validate_script_valid() {
1706        let valid_script = r#"
1707import http from 'k6/http';
1708import { check, sleep } from 'k6';
1709import { Rate, Trend } from 'k6/metrics';
1710
1711const test_latency = new Trend('test_latency');
1712const test_errors = new Rate('test_errors');
1713
1714export default function() {
1715    const res = http.get('https://example.com');
1716    test_latency.add(res.timings.duration);
1717    test_errors.add(res.status !== 200);
1718}
1719"#;
1720
1721        let errors = K6ScriptGenerator::validate_script(valid_script);
1722        assert!(errors.is_empty(), "Valid script should have no validation errors");
1723    }
1724
1725    #[test]
1726    fn test_validate_script_invalid_metric_name() {
1727        let invalid_script = r#"
1728import http from 'k6/http';
1729import { check, sleep } from 'k6';
1730import { Rate, Trend } from 'k6/metrics';
1731
1732const test_latency = new Trend('test.latency');
1733const test_errors = new Rate('test_errors');
1734
1735export default function() {
1736    const res = http.get('https://example.com');
1737    test_latency.add(res.timings.duration);
1738}
1739"#;
1740
1741        let errors = K6ScriptGenerator::validate_script(invalid_script);
1742        assert!(
1743            !errors.is_empty(),
1744            "Script with invalid metric name should have validation errors"
1745        );
1746        assert!(
1747            errors.iter().any(|e| e.contains("Invalid k6 metric name")),
1748            "Should detect invalid metric name with dot"
1749        );
1750    }
1751
1752    #[test]
1753    fn test_validate_script_missing_imports() {
1754        let invalid_script = r#"
1755const test_latency = new Trend('test_latency');
1756export default function() {}
1757"#;
1758
1759        let errors = K6ScriptGenerator::validate_script(invalid_script);
1760        assert!(!errors.is_empty(), "Script missing imports should have validation errors");
1761    }
1762
1763    #[test]
1764    fn test_validate_script_metric_name_validation() {
1765        // Test that validate_script correctly identifies invalid metric names
1766        // Valid metric names should pass
1767        let valid_script = r#"
1768import http from 'k6/http';
1769import { check, sleep } from 'k6';
1770import { Rate, Trend } from 'k6/metrics';
1771const test_latency = new Trend('test_latency');
1772const test_errors = new Rate('test_errors');
1773export default function() {}
1774"#;
1775        let errors = K6ScriptGenerator::validate_script(valid_script);
1776        assert!(errors.is_empty(), "Valid metric names should pass validation");
1777
1778        // Invalid metric names should fail
1779        let invalid_cases = vec![
1780            ("test.latency", "dot in metric name"),
1781            ("123test", "starts with number"),
1782            ("test-latency", "hyphen in metric name"),
1783            ("test@latency", "special character"),
1784        ];
1785
1786        for (invalid_name, description) in invalid_cases {
1787            let script = format!(
1788                r#"
1789import http from 'k6/http';
1790import {{ check, sleep }} from 'k6';
1791import {{ Rate, Trend }} from 'k6/metrics';
1792const test_latency = new Trend('{}');
1793export default function() {{}}
1794"#,
1795                invalid_name
1796            );
1797            let errors = K6ScriptGenerator::validate_script(&script);
1798            assert!(
1799                !errors.is_empty(),
1800                "Metric name '{}' ({}) should fail validation",
1801                invalid_name,
1802                description
1803            );
1804        }
1805    }
1806
1807    #[test]
1808    fn test_skip_tls_verify_with_body() {
1809        use crate::spec_parser::ApiOperation;
1810        use openapiv3::Operation;
1811        use serde_json::json;
1812
1813        // Create an operation with a request body
1814        let operation = ApiOperation {
1815            method: "post".to_string(),
1816            path: "/api/users".to_string(),
1817            operation: Operation::default(),
1818            operation_id: Some("createUser".to_string()),
1819        };
1820
1821        let template = RequestTemplate {
1822            operation,
1823            path_params: HashMap::new(),
1824            query_params: HashMap::new(),
1825            headers: HashMap::new(),
1826            body: Some(json!({"name": "test"})),
1827        };
1828
1829        let config = K6Config {
1830            target_url: "https://api.example.com".to_string(),
1831            base_path: None,
1832            scenario: LoadScenario::Constant,
1833            duration_secs: 30,
1834            max_vus: 5,
1835            threshold_percentile: "p(95)".to_string(),
1836            threshold_ms: 500,
1837            max_error_rate: 0.05,
1838            auth_header: None,
1839            custom_headers: HashMap::new(),
1840            skip_tls_verify: true,
1841            security_testing_enabled: false,
1842            chunked_request_bodies: false,
1843            target_rps: None,
1844            no_keep_alive: false,
1845            geo_source_ips: Vec::new(),
1846            geo_source_headers: Vec::new(),
1847        };
1848
1849        let generator = K6ScriptGenerator::new(config, vec![template]);
1850        let script = generator.generate().expect("Should generate script");
1851
1852        // Verify the script includes TLS skip option for requests with body
1853        assert!(
1854            script.contains("insecureSkipTLSVerify: true"),
1855            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
1856        );
1857    }
1858
1859    #[test]
1860    fn test_skip_tls_verify_without_body() {
1861        use crate::spec_parser::ApiOperation;
1862        use openapiv3::Operation;
1863
1864        // Create an operation without a request body
1865        let operation = ApiOperation {
1866            method: "get".to_string(),
1867            path: "/api/users".to_string(),
1868            operation: Operation::default(),
1869            operation_id: Some("getUsers".to_string()),
1870        };
1871
1872        let template = RequestTemplate {
1873            operation,
1874            path_params: HashMap::new(),
1875            query_params: HashMap::new(),
1876            headers: HashMap::new(),
1877            body: None,
1878        };
1879
1880        let config = K6Config {
1881            target_url: "https://api.example.com".to_string(),
1882            base_path: None,
1883            scenario: LoadScenario::Constant,
1884            duration_secs: 30,
1885            max_vus: 5,
1886            threshold_percentile: "p(95)".to_string(),
1887            threshold_ms: 500,
1888            max_error_rate: 0.05,
1889            auth_header: None,
1890            custom_headers: HashMap::new(),
1891            skip_tls_verify: true,
1892            security_testing_enabled: false,
1893            chunked_request_bodies: false,
1894            target_rps: None,
1895            no_keep_alive: false,
1896            geo_source_ips: Vec::new(),
1897            geo_source_headers: Vec::new(),
1898        };
1899
1900        let generator = K6ScriptGenerator::new(config, vec![template]);
1901        let script = generator.generate().expect("Should generate script");
1902
1903        // Verify the script includes TLS skip option for requests without body
1904        assert!(
1905            script.contains("insecureSkipTLSVerify: true"),
1906            "Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
1907        );
1908    }
1909
1910    #[test]
1911    fn test_no_skip_tls_verify() {
1912        use crate::spec_parser::ApiOperation;
1913        use openapiv3::Operation;
1914
1915        // Create an operation
1916        let operation = ApiOperation {
1917            method: "get".to_string(),
1918            path: "/api/users".to_string(),
1919            operation: Operation::default(),
1920            operation_id: Some("getUsers".to_string()),
1921        };
1922
1923        let template = RequestTemplate {
1924            operation,
1925            path_params: HashMap::new(),
1926            query_params: HashMap::new(),
1927            headers: HashMap::new(),
1928            body: None,
1929        };
1930
1931        let config = K6Config {
1932            target_url: "https://api.example.com".to_string(),
1933            base_path: None,
1934            scenario: LoadScenario::Constant,
1935            duration_secs: 30,
1936            max_vus: 5,
1937            threshold_percentile: "p(95)".to_string(),
1938            threshold_ms: 500,
1939            max_error_rate: 0.05,
1940            auth_header: None,
1941            custom_headers: HashMap::new(),
1942            skip_tls_verify: false,
1943            security_testing_enabled: false,
1944            chunked_request_bodies: false,
1945            target_rps: None,
1946            no_keep_alive: false,
1947            geo_source_ips: Vec::new(),
1948            geo_source_headers: Vec::new(),
1949        };
1950
1951        let generator = K6ScriptGenerator::new(config, vec![template]);
1952        let script = generator.generate().expect("Should generate script");
1953
1954        // Verify the script does NOT include TLS skip option when skip_tls_verify is false
1955        assert!(
1956            !script.contains("insecureSkipTLSVerify"),
1957            "Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_skip_tls_verify_multiple_operations() {
1963        use crate::spec_parser::ApiOperation;
1964        use openapiv3::Operation;
1965        use serde_json::json;
1966
1967        // Create multiple operations - one with body, one without
1968        let operation1 = ApiOperation {
1969            method: "get".to_string(),
1970            path: "/api/users".to_string(),
1971            operation: Operation::default(),
1972            operation_id: Some("getUsers".to_string()),
1973        };
1974
1975        let operation2 = ApiOperation {
1976            method: "post".to_string(),
1977            path: "/api/users".to_string(),
1978            operation: Operation::default(),
1979            operation_id: Some("createUser".to_string()),
1980        };
1981
1982        let template1 = RequestTemplate {
1983            operation: operation1,
1984            path_params: HashMap::new(),
1985            query_params: HashMap::new(),
1986            headers: HashMap::new(),
1987            body: None,
1988        };
1989
1990        let template2 = RequestTemplate {
1991            operation: operation2,
1992            path_params: HashMap::new(),
1993            query_params: HashMap::new(),
1994            headers: HashMap::new(),
1995            body: Some(json!({"name": "test"})),
1996        };
1997
1998        let config = K6Config {
1999            target_url: "https://api.example.com".to_string(),
2000            base_path: None,
2001            scenario: LoadScenario::Constant,
2002            duration_secs: 30,
2003            max_vus: 5,
2004            threshold_percentile: "p(95)".to_string(),
2005            threshold_ms: 500,
2006            max_error_rate: 0.05,
2007            auth_header: None,
2008            custom_headers: HashMap::new(),
2009            skip_tls_verify: true,
2010            security_testing_enabled: false,
2011            chunked_request_bodies: false,
2012            target_rps: None,
2013            no_keep_alive: false,
2014            geo_source_ips: Vec::new(),
2015            geo_source_headers: Vec::new(),
2016        };
2017
2018        let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
2019        let script = generator.generate().expect("Should generate script");
2020
2021        // Verify the script includes TLS skip option ONCE in global options
2022        // (k6 only supports insecureSkipTLSVerify as a global option, not per-request)
2023        let skip_count = script.matches("insecureSkipTLSVerify: true").count();
2024        assert_eq!(
2025            skip_count, 1,
2026            "Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
2027        );
2028
2029        // Verify it appears in the options block, before scenarios
2030        let options_start = script.find("export const options = {").expect("Should have options");
2031        let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
2032        let options_prefix = &script[options_start..scenarios_start];
2033        assert!(
2034            options_prefix.contains("insecureSkipTLSVerify: true"),
2035            "insecureSkipTLSVerify should be in global options block"
2036        );
2037    }
2038
2039    #[test]
2040    fn test_dynamic_params_in_body() {
2041        use crate::spec_parser::ApiOperation;
2042        use openapiv3::Operation;
2043        use serde_json::json;
2044
2045        // Create an operation with dynamic placeholders in the body
2046        let operation = ApiOperation {
2047            method: "post".to_string(),
2048            path: "/api/resources".to_string(),
2049            operation: Operation::default(),
2050            operation_id: Some("createResource".to_string()),
2051        };
2052
2053        let template = RequestTemplate {
2054            operation,
2055            path_params: HashMap::new(),
2056            query_params: HashMap::new(),
2057            headers: HashMap::new(),
2058            body: Some(json!({
2059                "name": "load-test-${__VU}",
2060                "iteration": "${__ITER}"
2061            })),
2062        };
2063
2064        let config = K6Config {
2065            target_url: "https://api.example.com".to_string(),
2066            base_path: None,
2067            scenario: LoadScenario::Constant,
2068            duration_secs: 30,
2069            max_vus: 5,
2070            threshold_percentile: "p(95)".to_string(),
2071            threshold_ms: 500,
2072            max_error_rate: 0.05,
2073            auth_header: None,
2074            custom_headers: HashMap::new(),
2075            skip_tls_verify: false,
2076            security_testing_enabled: false,
2077            chunked_request_bodies: false,
2078            target_rps: None,
2079            no_keep_alive: false,
2080            geo_source_ips: Vec::new(),
2081            geo_source_headers: Vec::new(),
2082        };
2083
2084        let generator = K6ScriptGenerator::new(config, vec![template]);
2085        let script = generator.generate().expect("Should generate script");
2086
2087        // Verify the script contains dynamic body indication
2088        assert!(
2089            script.contains("Dynamic body with runtime placeholders"),
2090            "Script should contain comment about dynamic body"
2091        );
2092
2093        // Verify the script contains the __VU variable reference
2094        assert!(
2095            script.contains("__VU"),
2096            "Script should contain __VU reference for dynamic VU-based values"
2097        );
2098
2099        // Verify the script contains the __ITER variable reference
2100        assert!(
2101            script.contains("__ITER"),
2102            "Script should contain __ITER reference for dynamic iteration values"
2103        );
2104    }
2105
2106    #[test]
2107    fn test_dynamic_params_with_uuid() {
2108        use crate::spec_parser::ApiOperation;
2109        use openapiv3::Operation;
2110        use serde_json::json;
2111
2112        // Create an operation with UUID placeholder
2113        let operation = ApiOperation {
2114            method: "post".to_string(),
2115            path: "/api/resources".to_string(),
2116            operation: Operation::default(),
2117            operation_id: Some("createResource".to_string()),
2118        };
2119
2120        let template = RequestTemplate {
2121            operation,
2122            path_params: HashMap::new(),
2123            query_params: HashMap::new(),
2124            headers: HashMap::new(),
2125            body: Some(json!({
2126                "id": "${__UUID}"
2127            })),
2128        };
2129
2130        let config = K6Config {
2131            target_url: "https://api.example.com".to_string(),
2132            base_path: None,
2133            scenario: LoadScenario::Constant,
2134            duration_secs: 30,
2135            max_vus: 5,
2136            threshold_percentile: "p(95)".to_string(),
2137            threshold_ms: 500,
2138            max_error_rate: 0.05,
2139            auth_header: None,
2140            custom_headers: HashMap::new(),
2141            skip_tls_verify: false,
2142            security_testing_enabled: false,
2143            chunked_request_bodies: false,
2144            target_rps: None,
2145            no_keep_alive: false,
2146            geo_source_ips: Vec::new(),
2147            geo_source_headers: Vec::new(),
2148        };
2149
2150        let generator = K6ScriptGenerator::new(config, vec![template]);
2151        let script = generator.generate().expect("Should generate script");
2152
2153        // As of k6 v1.0.0+, webcrypto is globally available - no import needed
2154        // Verify the script does NOT include the old experimental webcrypto import
2155        assert!(
2156            !script.contains("k6/experimental/webcrypto"),
2157            "Script should NOT include deprecated k6/experimental/webcrypto import"
2158        );
2159
2160        // Verify crypto.randomUUID() is in the generated code
2161        assert!(
2162            script.contains("crypto.randomUUID()"),
2163            "Script should contain crypto.randomUUID() for UUID placeholder"
2164        );
2165    }
2166
2167    #[test]
2168    fn test_dynamic_params_with_counter() {
2169        use crate::spec_parser::ApiOperation;
2170        use openapiv3::Operation;
2171        use serde_json::json;
2172
2173        // Create an operation with COUNTER placeholder
2174        let operation = ApiOperation {
2175            method: "post".to_string(),
2176            path: "/api/resources".to_string(),
2177            operation: Operation::default(),
2178            operation_id: Some("createResource".to_string()),
2179        };
2180
2181        let template = RequestTemplate {
2182            operation,
2183            path_params: HashMap::new(),
2184            query_params: HashMap::new(),
2185            headers: HashMap::new(),
2186            body: Some(json!({
2187                "sequence": "${__COUNTER}"
2188            })),
2189        };
2190
2191        let config = K6Config {
2192            target_url: "https://api.example.com".to_string(),
2193            base_path: None,
2194            scenario: LoadScenario::Constant,
2195            duration_secs: 30,
2196            max_vus: 5,
2197            threshold_percentile: "p(95)".to_string(),
2198            threshold_ms: 500,
2199            max_error_rate: 0.05,
2200            auth_header: None,
2201            custom_headers: HashMap::new(),
2202            skip_tls_verify: false,
2203            security_testing_enabled: false,
2204            chunked_request_bodies: false,
2205            target_rps: None,
2206            no_keep_alive: false,
2207            geo_source_ips: Vec::new(),
2208            geo_source_headers: Vec::new(),
2209        };
2210
2211        let generator = K6ScriptGenerator::new(config, vec![template]);
2212        let script = generator.generate().expect("Should generate script");
2213
2214        // Verify the script includes the global counter initialization
2215        assert!(
2216            script.contains("let globalCounter = 0"),
2217            "Script should include globalCounter initialization when COUNTER placeholder is used"
2218        );
2219
2220        // Verify globalCounter++ is in the generated code
2221        assert!(
2222            script.contains("globalCounter++"),
2223            "Script should contain globalCounter++ for COUNTER placeholder"
2224        );
2225    }
2226
2227    #[test]
2228    fn test_static_body_no_dynamic_marker() {
2229        use crate::spec_parser::ApiOperation;
2230        use openapiv3::Operation;
2231        use serde_json::json;
2232
2233        // Create an operation with static body (no placeholders)
2234        let operation = ApiOperation {
2235            method: "post".to_string(),
2236            path: "/api/resources".to_string(),
2237            operation: Operation::default(),
2238            operation_id: Some("createResource".to_string()),
2239        };
2240
2241        let template = RequestTemplate {
2242            operation,
2243            path_params: HashMap::new(),
2244            query_params: HashMap::new(),
2245            headers: HashMap::new(),
2246            body: Some(json!({
2247                "name": "static-value",
2248                "count": 42
2249            })),
2250        };
2251
2252        let config = K6Config {
2253            target_url: "https://api.example.com".to_string(),
2254            base_path: None,
2255            scenario: LoadScenario::Constant,
2256            duration_secs: 30,
2257            max_vus: 5,
2258            threshold_percentile: "p(95)".to_string(),
2259            threshold_ms: 500,
2260            max_error_rate: 0.05,
2261            auth_header: None,
2262            custom_headers: HashMap::new(),
2263            skip_tls_verify: false,
2264            security_testing_enabled: false,
2265            chunked_request_bodies: false,
2266            target_rps: None,
2267            no_keep_alive: false,
2268            geo_source_ips: Vec::new(),
2269            geo_source_headers: Vec::new(),
2270        };
2271
2272        let generator = K6ScriptGenerator::new(config, vec![template]);
2273        let script = generator.generate().expect("Should generate script");
2274
2275        // Verify the script does NOT contain dynamic body marker
2276        assert!(
2277            !script.contains("Dynamic body with runtime placeholders"),
2278            "Script should NOT contain dynamic body comment for static body"
2279        );
2280
2281        // Verify it does NOT include unnecessary crypto imports
2282        assert!(
2283            !script.contains("webcrypto"),
2284            "Script should NOT include webcrypto import for static body"
2285        );
2286
2287        // Verify it does NOT include global counter
2288        assert!(
2289            !script.contains("let globalCounter"),
2290            "Script should NOT include globalCounter for static body"
2291        );
2292    }
2293
2294    #[test]
2295    fn test_security_testing_enabled_generates_calling_code() {
2296        use crate::spec_parser::ApiOperation;
2297        use openapiv3::Operation;
2298        use serde_json::json;
2299
2300        let operation = ApiOperation {
2301            method: "post".to_string(),
2302            path: "/api/users".to_string(),
2303            operation: Operation::default(),
2304            operation_id: Some("createUser".to_string()),
2305        };
2306
2307        let template = RequestTemplate {
2308            operation,
2309            path_params: HashMap::new(),
2310            query_params: HashMap::new(),
2311            headers: HashMap::new(),
2312            body: Some(json!({"name": "test"})),
2313        };
2314
2315        let config = K6Config {
2316            target_url: "https://api.example.com".to_string(),
2317            base_path: None,
2318            scenario: LoadScenario::Constant,
2319            duration_secs: 30,
2320            max_vus: 5,
2321            threshold_percentile: "p(95)".to_string(),
2322            threshold_ms: 500,
2323            max_error_rate: 0.05,
2324            auth_header: None,
2325            custom_headers: HashMap::new(),
2326            skip_tls_verify: false,
2327            security_testing_enabled: true,
2328            chunked_request_bodies: false,
2329            target_rps: None,
2330            no_keep_alive: false,
2331            geo_source_ips: Vec::new(),
2332            geo_source_headers: Vec::new(),
2333        };
2334
2335        let generator = K6ScriptGenerator::new(config, vec![template]);
2336        let script = generator.generate().expect("Should generate script");
2337
2338        // Verify calling code is generated (not just function definitions)
2339        assert!(
2340            script.contains("getNextSecurityPayload"),
2341            "Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
2342        );
2343        assert!(
2344            script.contains("applySecurityPayload"),
2345            "Script should contain applySecurityPayload() call when security_testing_enabled is true"
2346        );
2347        assert!(
2348            script.contains("secPayloadGroup"),
2349            "Script should contain secPayloadGroup variable when security_testing_enabled is true"
2350        );
2351        assert!(
2352            script.contains("secBodyPayload"),
2353            "Script should contain secBodyPayload variable when security_testing_enabled is true"
2354        );
2355        // Verify CookieJar skip when Cookie header payload is present
2356        assert!(
2357            script.contains("hasSecCookie"),
2358            "Script should track hasSecCookie for CookieJar conflict avoidance"
2359        );
2360        assert!(
2361            script.contains("secRequestOpts"),
2362            "Script should use secRequestOpts to conditionally skip CookieJar"
2363        );
2364        // Verify mutable headers copy for injection
2365        assert!(
2366            script.contains("const requestHeaders = { ..."),
2367            "Script should spread headers into mutable copy for security payload injection"
2368        );
2369        // Verify injectAsPath handling for path-based URI injection
2370        assert!(
2371            script.contains("secPayload.injectAsPath"),
2372            "Script should check injectAsPath for path-based URI injection"
2373        );
2374        // Verify formBody handling for form-encoded body delivery
2375        assert!(
2376            script.contains("secBodyPayload.formBody"),
2377            "Script should check formBody for form-encoded body delivery"
2378        );
2379        assert!(
2380            script.contains("application/x-www-form-urlencoded"),
2381            "Script should set Content-Type for form-encoded body"
2382        );
2383        // Verify secPayloadGroup is fetched per-operation (inside operation block), not per-iteration
2384        let op_comment_pos =
2385            script.find("// Operation 0:").expect("Should have Operation 0 comment");
2386        let sec_payload_pos = script
2387            .find("const secPayloadGroup = typeof getNextSecurityPayload")
2388            .expect("Should have secPayloadGroup assignment");
2389        assert!(
2390            sec_payload_pos > op_comment_pos,
2391            "secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
2392        );
2393    }
2394
2395    #[test]
2396    fn test_security_testing_disabled_no_calling_code() {
2397        use crate::spec_parser::ApiOperation;
2398        use openapiv3::Operation;
2399        use serde_json::json;
2400
2401        let operation = ApiOperation {
2402            method: "post".to_string(),
2403            path: "/api/users".to_string(),
2404            operation: Operation::default(),
2405            operation_id: Some("createUser".to_string()),
2406        };
2407
2408        let template = RequestTemplate {
2409            operation,
2410            path_params: HashMap::new(),
2411            query_params: HashMap::new(),
2412            headers: HashMap::new(),
2413            body: Some(json!({"name": "test"})),
2414        };
2415
2416        let config = K6Config {
2417            target_url: "https://api.example.com".to_string(),
2418            base_path: None,
2419            scenario: LoadScenario::Constant,
2420            duration_secs: 30,
2421            max_vus: 5,
2422            threshold_percentile: "p(95)".to_string(),
2423            threshold_ms: 500,
2424            max_error_rate: 0.05,
2425            auth_header: None,
2426            custom_headers: HashMap::new(),
2427            skip_tls_verify: false,
2428            security_testing_enabled: false,
2429            chunked_request_bodies: false,
2430            target_rps: None,
2431            no_keep_alive: false,
2432            geo_source_ips: Vec::new(),
2433            geo_source_headers: Vec::new(),
2434        };
2435
2436        let generator = K6ScriptGenerator::new(config, vec![template]);
2437        let script = generator.generate().expect("Should generate script");
2438
2439        // Verify calling code is NOT generated
2440        assert!(
2441            !script.contains("getNextSecurityPayload"),
2442            "Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
2443        );
2444        assert!(
2445            !script.contains("applySecurityPayload"),
2446            "Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
2447        );
2448        assert!(
2449            !script.contains("secPayloadGroup"),
2450            "Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
2451        );
2452        assert!(
2453            !script.contains("secBodyPayload"),
2454            "Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
2455        );
2456        assert!(
2457            !script.contains("hasSecCookie"),
2458            "Script should NOT contain hasSecCookie when security_testing_enabled is false"
2459        );
2460        assert!(
2461            !script.contains("secRequestOpts"),
2462            "Script should NOT contain secRequestOpts when security_testing_enabled is false"
2463        );
2464        assert!(
2465            !script.contains("injectAsPath"),
2466            "Script should NOT contain injectAsPath when security_testing_enabled is false"
2467        );
2468        assert!(
2469            !script.contains("formBody"),
2470            "Script should NOT contain formBody when security_testing_enabled is false"
2471        );
2472    }
2473
2474    /// End-to-end test: simulates the real pipeline of template rendering + enhanced script
2475    /// injection. This is what actually runs when a user passes `--security-test`.
2476    /// Verifies that the FINAL script has both function definitions AND calling code.
2477    #[test]
2478    fn test_security_e2e_definitions_and_calls_both_present() {
2479        use crate::security_payloads::{
2480            SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
2481        };
2482        use crate::spec_parser::ApiOperation;
2483        use openapiv3::Operation;
2484        use serde_json::json;
2485
2486        // Step 1: Generate base script with security_testing_enabled=true (template renders calling code)
2487        let operation = ApiOperation {
2488            method: "post".to_string(),
2489            path: "/api/users".to_string(),
2490            operation: Operation::default(),
2491            operation_id: Some("createUser".to_string()),
2492        };
2493
2494        let template = RequestTemplate {
2495            operation,
2496            path_params: HashMap::new(),
2497            query_params: HashMap::new(),
2498            headers: HashMap::new(),
2499            body: Some(json!({"name": "test"})),
2500        };
2501
2502        let config = K6Config {
2503            target_url: "https://api.example.com".to_string(),
2504            base_path: None,
2505            scenario: LoadScenario::Constant,
2506            duration_secs: 30,
2507            max_vus: 5,
2508            threshold_percentile: "p(95)".to_string(),
2509            threshold_ms: 500,
2510            max_error_rate: 0.05,
2511            auth_header: None,
2512            custom_headers: HashMap::new(),
2513            skip_tls_verify: false,
2514            security_testing_enabled: true,
2515            chunked_request_bodies: false,
2516            target_rps: None,
2517            no_keep_alive: false,
2518            geo_source_ips: Vec::new(),
2519            geo_source_headers: Vec::new(),
2520        };
2521
2522        let generator = K6ScriptGenerator::new(config, vec![template]);
2523        let mut script = generator.generate().expect("Should generate base script");
2524
2525        // Step 2: Simulate what generate_enhanced_script() does — inject function definitions
2526        let security_config = SecurityTestConfig::default().enable();
2527        let payloads = SecurityPayloads::get_payloads(&security_config);
2528        assert!(!payloads.is_empty(), "Should have built-in payloads");
2529
2530        let mut additional_code = String::new();
2531        additional_code
2532            .push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
2533        additional_code.push('\n');
2534        additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
2535        additional_code.push('\n');
2536
2537        // Insert definitions before 'export const options' (same as generate_enhanced_script)
2538        if let Some(pos) = script.find("export const options") {
2539            script.insert_str(
2540                pos,
2541                &format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
2542            );
2543        }
2544
2545        // Step 3: Verify the FINAL script has BOTH definitions AND calls
2546        // Function definitions (injected by generate_enhanced_script)
2547        assert!(
2548            script.contains("function getNextSecurityPayload()"),
2549            "Final script must contain getNextSecurityPayload function DEFINITION"
2550        );
2551        assert!(
2552            script.contains("function applySecurityPayload("),
2553            "Final script must contain applySecurityPayload function DEFINITION"
2554        );
2555        assert!(
2556            script.contains("securityPayloads"),
2557            "Final script must contain securityPayloads array"
2558        );
2559
2560        // Calling code (rendered by template)
2561        assert!(
2562            script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
2563            "Final script must contain secPayloadGroup assignment (template calling code)"
2564        );
2565        assert!(
2566            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2567            "Final script must contain applySecurityPayload CALL with secBodyPayload"
2568        );
2569        assert!(
2570            script.contains("const requestHeaders = { ..."),
2571            "Final script must spread headers for security payload header injection"
2572        );
2573        assert!(
2574            script.contains("for (const secPayload of secPayloadGroup)"),
2575            "Final script must loop over secPayloadGroup"
2576        );
2577        assert!(
2578            script.contains("secPayload.injectAsPath"),
2579            "Final script must check injectAsPath for path-based URI injection"
2580        );
2581        assert!(
2582            script.contains("secBodyPayload.formBody"),
2583            "Final script must check formBody for form-encoded body delivery"
2584        );
2585
2586        // Verify ordering: definitions come BEFORE export default function (which has the calls)
2587        let def_pos = script.find("function getNextSecurityPayload()").unwrap();
2588        let call_pos =
2589            script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
2590        let options_pos = script.find("export const options").unwrap();
2591        let default_fn_pos = script.find("export default function").unwrap();
2592
2593        assert!(
2594            def_pos < options_pos,
2595            "Function definitions must appear before export const options"
2596        );
2597        assert!(
2598            call_pos > default_fn_pos,
2599            "Calling code must appear inside export default function"
2600        );
2601    }
2602
2603    /// Test that URI security payload injection is generated for GET requests
2604    #[test]
2605    fn test_security_uri_injection_for_get_requests() {
2606        use crate::spec_parser::ApiOperation;
2607        use openapiv3::Operation;
2608
2609        let operation = ApiOperation {
2610            method: "get".to_string(),
2611            path: "/api/users".to_string(),
2612            operation: Operation::default(),
2613            operation_id: Some("listUsers".to_string()),
2614        };
2615
2616        let template = RequestTemplate {
2617            operation,
2618            path_params: HashMap::new(),
2619            query_params: HashMap::new(),
2620            headers: HashMap::new(),
2621            body: None,
2622        };
2623
2624        let config = K6Config {
2625            target_url: "https://api.example.com".to_string(),
2626            base_path: None,
2627            scenario: LoadScenario::Constant,
2628            duration_secs: 30,
2629            max_vus: 5,
2630            threshold_percentile: "p(95)".to_string(),
2631            threshold_ms: 500,
2632            max_error_rate: 0.05,
2633            auth_header: None,
2634            custom_headers: HashMap::new(),
2635            skip_tls_verify: false,
2636            security_testing_enabled: true,
2637            chunked_request_bodies: false,
2638            target_rps: None,
2639            no_keep_alive: false,
2640            geo_source_ips: Vec::new(),
2641            geo_source_headers: Vec::new(),
2642        };
2643
2644        let generator = K6ScriptGenerator::new(config, vec![template]);
2645        let script = generator.generate().expect("Should generate script");
2646
2647        // Verify URI injection code is present for GET requests
2648        assert!(
2649            script.contains("requestUrl"),
2650            "Script should build requestUrl variable for URI payload injection"
2651        );
2652        assert!(
2653            script.contains("secPayload.location === 'uri'"),
2654            "Script should check for URI-location payloads"
2655        );
2656        // URI payloads are URL-encoded for valid HTTP; WAF decodes before inspection
2657        assert!(
2658            script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
2659            "Script should URL-encode security payload in query string for valid HTTP"
2660        );
2661        // Verify injectAsPath check for path-based injection
2662        assert!(
2663            script.contains("secPayload.injectAsPath"),
2664            "Script should check injectAsPath for path-based URI injection"
2665        );
2666        assert!(
2667            script.contains("encodeURI(secPayload.payload)"),
2668            "Script should use encodeURI for path-based injection"
2669        );
2670        // Verify the GET request uses requestUrl
2671        assert!(
2672            script.contains("http.get(requestUrl,"),
2673            "GET request should use requestUrl (with URI injection) instead of inline URL"
2674        );
2675    }
2676
2677    /// Test that URI security payload injection is generated for POST requests with body
2678    #[test]
2679    fn test_security_uri_injection_for_post_requests() {
2680        use crate::spec_parser::ApiOperation;
2681        use openapiv3::Operation;
2682        use serde_json::json;
2683
2684        let operation = ApiOperation {
2685            method: "post".to_string(),
2686            path: "/api/users".to_string(),
2687            operation: Operation::default(),
2688            operation_id: Some("createUser".to_string()),
2689        };
2690
2691        let template = RequestTemplate {
2692            operation,
2693            path_params: HashMap::new(),
2694            query_params: HashMap::new(),
2695            headers: HashMap::new(),
2696            body: Some(json!({"name": "test"})),
2697        };
2698
2699        let config = K6Config {
2700            target_url: "https://api.example.com".to_string(),
2701            base_path: None,
2702            scenario: LoadScenario::Constant,
2703            duration_secs: 30,
2704            max_vus: 5,
2705            threshold_percentile: "p(95)".to_string(),
2706            threshold_ms: 500,
2707            max_error_rate: 0.05,
2708            auth_header: None,
2709            custom_headers: HashMap::new(),
2710            skip_tls_verify: false,
2711            security_testing_enabled: true,
2712            chunked_request_bodies: false,
2713            target_rps: None,
2714            no_keep_alive: false,
2715            geo_source_ips: Vec::new(),
2716            geo_source_headers: Vec::new(),
2717        };
2718
2719        let generator = K6ScriptGenerator::new(config, vec![template]);
2720        let script = generator.generate().expect("Should generate script");
2721
2722        // POST with body should get BOTH URI injection AND body injection
2723        assert!(
2724            script.contains("requestUrl"),
2725            "POST script should build requestUrl for URI payload injection"
2726        );
2727        assert!(
2728            script.contains("secPayload.location === 'uri'"),
2729            "POST script should check for URI-location payloads"
2730        );
2731        assert!(
2732            script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
2733            "POST script should apply security body payload to request body"
2734        );
2735        // Verify the POST request uses requestUrl
2736        assert!(
2737            script.contains("http.post(requestUrl,"),
2738            "POST request should use requestUrl (with URI injection) instead of inline URL"
2739        );
2740    }
2741
2742    /// Test that security is disabled - no URI injection code present
2743    #[test]
2744    fn test_no_uri_injection_when_security_disabled() {
2745        use crate::spec_parser::ApiOperation;
2746        use openapiv3::Operation;
2747
2748        let operation = ApiOperation {
2749            method: "get".to_string(),
2750            path: "/api/users".to_string(),
2751            operation: Operation::default(),
2752            operation_id: Some("listUsers".to_string()),
2753        };
2754
2755        let template = RequestTemplate {
2756            operation,
2757            path_params: HashMap::new(),
2758            query_params: HashMap::new(),
2759            headers: HashMap::new(),
2760            body: None,
2761        };
2762
2763        let config = K6Config {
2764            target_url: "https://api.example.com".to_string(),
2765            base_path: None,
2766            scenario: LoadScenario::Constant,
2767            duration_secs: 30,
2768            max_vus: 5,
2769            threshold_percentile: "p(95)".to_string(),
2770            threshold_ms: 500,
2771            max_error_rate: 0.05,
2772            auth_header: None,
2773            custom_headers: HashMap::new(),
2774            skip_tls_verify: false,
2775            security_testing_enabled: false,
2776            chunked_request_bodies: false,
2777            target_rps: None,
2778            no_keep_alive: false,
2779            geo_source_ips: Vec::new(),
2780            geo_source_headers: Vec::new(),
2781        };
2782
2783        let generator = K6ScriptGenerator::new(config, vec![template]);
2784        let script = generator.generate().expect("Should generate script");
2785
2786        // Verify NO security injection code when disabled
2787        assert!(
2788            !script.contains("requestUrl"),
2789            "Script should NOT have requestUrl when security is disabled"
2790        );
2791        assert!(
2792            !script.contains("secPayloadGroup"),
2793            "Script should NOT have secPayloadGroup when security is disabled"
2794        );
2795        assert!(
2796            !script.contains("secBodyPayload"),
2797            "Script should NOT have secBodyPayload when security is disabled"
2798        );
2799    }
2800
2801    /// Test that scripts create a fresh CookieJar per request (not a shared constant)
2802    #[test]
2803    fn test_uses_per_request_cookie_jar() {
2804        use crate::spec_parser::ApiOperation;
2805        use openapiv3::Operation;
2806
2807        let operation = ApiOperation {
2808            method: "get".to_string(),
2809            path: "/api/users".to_string(),
2810            operation: Operation::default(),
2811            operation_id: Some("listUsers".to_string()),
2812        };
2813
2814        let template = RequestTemplate {
2815            operation,
2816            path_params: HashMap::new(),
2817            query_params: HashMap::new(),
2818            headers: HashMap::new(),
2819            body: None,
2820        };
2821
2822        let config = K6Config {
2823            target_url: "https://api.example.com".to_string(),
2824            base_path: None,
2825            scenario: LoadScenario::Constant,
2826            duration_secs: 30,
2827            max_vus: 5,
2828            threshold_percentile: "p(95)".to_string(),
2829            threshold_ms: 500,
2830            max_error_rate: 0.05,
2831            auth_header: None,
2832            custom_headers: HashMap::new(),
2833            skip_tls_verify: false,
2834            security_testing_enabled: false,
2835            chunked_request_bodies: false,
2836            target_rps: None,
2837            no_keep_alive: false,
2838            geo_source_ips: Vec::new(),
2839            geo_source_headers: Vec::new(),
2840        };
2841
2842        let generator = K6ScriptGenerator::new(config, vec![template]);
2843        let script = generator.generate().expect("Should generate script");
2844
2845        // Each request must create a fresh CookieJar to prevent Set-Cookie accumulation
2846        assert!(
2847            script.contains("jar: new http.CookieJar()"),
2848            "Script should create fresh CookieJar per request"
2849        );
2850        assert!(
2851            !script.contains("jar: null"),
2852            "Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
2853        );
2854        assert!(
2855            !script.contains("EMPTY_JAR"),
2856            "Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
2857        );
2858    }
2859
2860    /// Round 63 (#79): a `Connection` header stays in the script (it is the
2861    /// WAF case) and `force_http1` documents GODEBUG=http2client=0. Stripping
2862    /// the header would skip the hop-by-hop test.
2863    #[test]
2864    fn connection_header_forces_http1_comment_and_stays_on_the_wire() {
2865        use crate::spec_parser::ApiOperation;
2866        use openapiv3::Operation;
2867
2868        let operation = ApiOperation {
2869            method: "get".to_string(),
2870            path: "/hop".to_string(),
2871            operation: Operation::default(),
2872            operation_id: Some("hop".to_string()),
2873        };
2874        let mut headers = HashMap::new();
2875        headers.insert("Connection".to_string(), "Transfer-Encoding, keep-alive".to_string());
2876        let template = RequestTemplate {
2877            operation,
2878            path_params: HashMap::new(),
2879            query_params: HashMap::new(),
2880            headers,
2881            body: None,
2882        };
2883        let config = K6Config {
2884            target_url: "https://waf.example.com".to_string(),
2885            base_path: None,
2886            scenario: LoadScenario::Constant,
2887            duration_secs: 30,
2888            max_vus: 1,
2889            threshold_percentile: "p(95)".to_string(),
2890            threshold_ms: 500,
2891            max_error_rate: 0.05,
2892            auth_header: None,
2893            custom_headers: HashMap::new(),
2894            skip_tls_verify: true,
2895            security_testing_enabled: false,
2896            chunked_request_bodies: false,
2897            target_rps: None,
2898            no_keep_alive: false,
2899            geo_source_ips: Vec::new(),
2900            geo_source_headers: Vec::new(),
2901        };
2902        let generator = K6ScriptGenerator::new(config, vec![template]);
2903        assert!(generator.should_force_http1());
2904        let data = generator.build_template_data().expect("template data");
2905        assert!(data.force_http1);
2906        let script = generator.generate().expect("script generates");
2907        assert!(
2908            script.contains("GODEBUG=http2client=0"),
2909            "script must tell a manual k6 run to disable HTTP/2"
2910        );
2911        assert!(
2912            script.contains("Transfer-Encoding, keep-alive"),
2913            "Connection value must stay in the script; stripping it skips the WAF case"
2914        );
2915        assert!(
2916            script.contains("\"Connection\"") || script.contains("Connection"),
2917            "Connection header key must stay on the wire"
2918        );
2919    }
2920
2921    /// `--wafbench-verbatim` forces the HTTP/1.1 comment even when this
2922    /// particular file has no Connection header (the mix usually does).
2923    #[test]
2924    fn verbatim_flag_forces_http1_comment_without_connection_header() {
2925        let config = K6Config {
2926            target_url: "https://waf.example.com".to_string(),
2927            base_path: None,
2928            scenario: LoadScenario::Constant,
2929            duration_secs: 30,
2930            max_vus: 1,
2931            threshold_percentile: "p(95)".to_string(),
2932            threshold_ms: 500,
2933            max_error_rate: 0.05,
2934            auth_header: None,
2935            custom_headers: HashMap::new(),
2936            skip_tls_verify: true,
2937            security_testing_enabled: false,
2938            chunked_request_bodies: false,
2939            target_rps: None,
2940            no_keep_alive: false,
2941            geo_source_ips: Vec::new(),
2942            geo_source_headers: Vec::new(),
2943        };
2944        let generator = K6ScriptGenerator::new(config, vec![]).with_force_http1(true);
2945        assert!(generator.should_force_http1());
2946        let script = generator.generate().expect("script generates");
2947        assert!(script.contains("GODEBUG=http2client=0"));
2948    }
2949
2950    /// Round 65 (#79) — auto-off when op count or duration is huge.
2951    #[test]
2952    fn resolve_per_op_metrics_auto_and_explicit() {
2953        let (on, warn) = resolve_per_op_metrics(None, 10, 60);
2954        assert!(on);
2955        assert!(warn.is_none());
2956
2957        let (off, warn) = resolve_per_op_metrics(None, PER_OP_METRICS_AUTO_OPS_THRESHOLD, 60);
2958        assert!(!off);
2959        assert!(warn.as_ref().unwrap().contains("Auto-disabled"));
2960
2961        let (off, warn) = resolve_per_op_metrics(None, 10, PER_OP_METRICS_AUTO_DURATION_SECS);
2962        assert!(!off);
2963        assert!(warn.as_ref().unwrap().contains("duration"));
2964
2965        let (forced_on, warn) =
2966            resolve_per_op_metrics(Some(true), PER_OP_METRICS_AUTO_OPS_THRESHOLD, 86_400);
2967        assert!(forced_on);
2968        assert!(warn.is_none());
2969
2970        let (forced_off, warn) = resolve_per_op_metrics(Some(false), 1, 1);
2971        assert!(!forced_off);
2972        assert!(warn.is_none());
2973    }
2974
2975    #[test]
2976    fn resolve_max_concurrency_auto_caps_huge_specs() {
2977        let (n, warn) = resolve_max_concurrency(None, 10, 50);
2978        assert_eq!(n, MAX_CONCURRENCY_DEFAULT);
2979        assert!(warn.is_none());
2980
2981        let (n, warn) = resolve_max_concurrency(None, HUGE_SPEC_OPS_THRESHOLD, 50);
2982        assert_eq!(n, MAX_CONCURRENCY_HUGE_SPEC);
2983        assert!(warn.as_ref().unwrap().contains("Auto-capped"));
2984
2985        let (n, warn) = resolve_max_concurrency(Some(20), HUGE_SPEC_OPS_THRESHOLD, 50);
2986        assert_eq!(n, 20);
2987        assert!(warn.is_none());
2988
2989        // Never exceed target count.
2990        let (n, _) = resolve_max_concurrency(Some(100), 10, 3);
2991        assert_eq!(n, 3);
2992    }
2993
2994    /// Round 65 (#79) — huge-op scripts omit Trend/Rate per operation.
2995    #[test]
2996    fn per_op_metrics_false_omits_trend_rate_declarations() {
2997        use crate::spec_parser::ApiOperation;
2998        use openapiv3::Operation;
2999
3000        let config = K6Config {
3001            target_url: "http://localhost:3000".to_string(),
3002            base_path: None,
3003            scenario: LoadScenario::Constant,
3004            duration_secs: 30,
3005            max_vus: 1,
3006            threshold_percentile: "p(95)".to_string(),
3007            threshold_ms: 500,
3008            max_error_rate: 0.05,
3009            auth_header: None,
3010            custom_headers: HashMap::new(),
3011            skip_tls_verify: false,
3012            security_testing_enabled: false,
3013            chunked_request_bodies: false,
3014            target_rps: None,
3015            no_keep_alive: false,
3016            geo_source_ips: Vec::new(),
3017            geo_source_headers: Vec::new(),
3018        };
3019        let template = RequestTemplate {
3020            operation: ApiOperation {
3021                method: "get".to_string(),
3022                path: "/users".to_string(),
3023                operation: Operation::default(),
3024                operation_id: Some("get_users".to_string()),
3025            },
3026            path_params: HashMap::new(),
3027            query_params: HashMap::new(),
3028            headers: HashMap::new(),
3029            body: None,
3030        };
3031        let on_script = K6ScriptGenerator::new(
3032            K6Config {
3033                target_url: "http://localhost:3000".to_string(),
3034                base_path: None,
3035                scenario: LoadScenario::Constant,
3036                duration_secs: 30,
3037                max_vus: 1,
3038                threshold_percentile: "p(95)".to_string(),
3039                threshold_ms: 500,
3040                max_error_rate: 0.05,
3041                auth_header: None,
3042                custom_headers: HashMap::new(),
3043                skip_tls_verify: false,
3044                security_testing_enabled: false,
3045                chunked_request_bodies: false,
3046                target_rps: None,
3047                no_keep_alive: false,
3048                geo_source_ips: Vec::new(),
3049                geo_source_headers: Vec::new(),
3050            },
3051            vec![template.clone()],
3052        )
3053        .with_per_op_metrics(true)
3054        .generate()
3055        .unwrap();
3056        assert!(
3057            on_script.contains("new Trend(") && on_script.contains("_latency"),
3058            "per_op_metrics=true must emit per-op Trend"
3059        );
3060
3061        let off_script = K6ScriptGenerator::new(config, vec![template])
3062            .with_per_op_metrics(false)
3063            .generate()
3064            .unwrap();
3065        assert!(
3066            off_script.contains("Per-operation Trend/Rate metrics omitted"),
3067            "per_op_metrics=false must document the omission"
3068        );
3069        assert!(
3070            !off_script.contains("get_users_latency") && !off_script.contains("get_users_errors"),
3071            "per_op_metrics=false must not declare per-op Trend/Rate vars"
3072        );
3073    }
3074}