Skip to main content

mockforge_bench/
k6_gen.rs

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