use crate::dynamic_params::{DynamicParamProcessor, DynamicPlaceholder};
use crate::error::{BenchError, Result};
use crate::request_gen::RequestTemplate;
use crate::scenarios::LoadScenario;
use handlebars::Handlebars;
use serde::Serialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize)]
pub struct K6ScriptTemplateData {
pub base_url: String,
pub stages: Vec<K6StageData>,
pub operations: Vec<K6OperationData>,
pub threshold_percentile: String,
pub threshold_ms: u64,
pub max_error_rate: f64,
pub abort_on_error: bool,
pub abort_on_error_rate: f64,
pub scenario_name: String,
pub skip_tls_verify: bool,
pub has_dynamic_values: bool,
pub dynamic_imports: Vec<String>,
pub dynamic_globals: Vec<String>,
pub security_testing_enabled: bool,
pub has_custom_headers: bool,
pub chunked_request_bodies: bool,
pub target_rps: Option<u32>,
pub no_keep_alive: bool,
pub duration_secs: u64,
pub max_vus: u32,
pub start_vus: u32,
pub geo_source_ips: Vec<String>,
pub geo_source_headers: Vec<String>,
pub has_geo_source: bool,
pub geo_source_ips_json: String,
pub geo_source_headers_json: String,
pub force_http1: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct K6CrudFlowTemplateData {
pub base_url: String,
pub flows: Vec<Value>,
pub extract_fields: Vec<String>,
pub duration_secs: u64,
pub max_vus: u32,
pub auth_header: Option<String>,
pub custom_headers: HashMap<String, String>,
pub skip_tls_verify: bool,
pub stages: Vec<K6StageData>,
pub threshold_percentile: String,
pub threshold_ms: u64,
pub max_error_rate: f64,
pub headers: String,
pub dynamic_imports: Vec<String>,
pub dynamic_globals: Vec<String>,
pub extracted_values_output_path: String,
pub error_injection_enabled: bool,
pub error_rate: f64,
pub error_types: Vec<String>,
pub security_testing_enabled: bool,
pub has_custom_headers: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct K6StageData {
pub duration: String,
pub target: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct K6OperationData {
pub index: usize,
pub name: String,
pub metric_name: String,
pub display_name: String,
pub method: String,
pub path: Value,
pub path_is_dynamic: bool,
pub headers: Value,
pub body: Option<Value>,
pub body_is_dynamic: bool,
pub has_body: bool,
pub is_get_or_head: bool,
}
pub struct K6Config {
pub target_url: String,
pub base_path: Option<String>,
pub scenario: LoadScenario,
pub duration_secs: u64,
pub max_vus: u32,
pub threshold_percentile: String,
pub threshold_ms: u64,
pub max_error_rate: f64,
pub auth_header: Option<String>,
pub custom_headers: HashMap<String, String>,
pub skip_tls_verify: bool,
pub security_testing_enabled: bool,
pub chunked_request_bodies: bool,
pub target_rps: Option<u32>,
pub no_keep_alive: bool,
pub geo_source_ips: Vec<String>,
pub geo_source_headers: Vec<String>,
}
pub struct K6ScriptGenerator {
config: K6Config,
templates: Vec<RequestTemplate>,
abort_on_error: bool,
abort_on_error_rate: f64,
force_http1: bool,
}
impl K6ScriptGenerator {
pub fn new(config: K6Config, templates: Vec<RequestTemplate>) -> Self {
Self {
config,
templates,
abort_on_error: true,
abort_on_error_rate: 0.95,
force_http1: false,
}
}
#[must_use]
pub fn with_force_http1(mut self, force_http1: bool) -> Self {
self.force_http1 = force_http1;
self
}
#[must_use]
pub fn with_abort_valve(mut self, abort_on_error: bool, abort_on_error_rate: f64) -> Self {
self.abort_on_error = abort_on_error;
self.abort_on_error_rate = abort_on_error_rate;
self
}
pub fn should_force_http1(&self) -> bool {
crate::request_gen::should_force_k6_http1(
self.force_http1,
&self.templates,
&self.config.custom_headers,
)
}
pub fn generate(&self) -> Result<String> {
let handlebars = Handlebars::new();
let template = include_str!("templates/k6_script.hbs");
let data = self.build_template_data()?;
let value = serde_json::to_value(&data)
.map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))?;
handlebars
.render_template(template, &value)
.map_err(|e| BenchError::ScriptGenerationFailed(e.to_string()))
}
const K6_METRIC_NAME_BASE_MAX_LEN: usize = 112;
pub fn sanitize_k6_metric_name(name: &str) -> String {
let sanitized = Self::sanitize_js_identifier(name);
if sanitized.len() <= Self::K6_METRIC_NAME_BASE_MAX_LEN {
return sanitized;
}
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
let hash_suffix = format!("{:08x}", hasher.finish() as u32);
let prefix_len = Self::K6_METRIC_NAME_BASE_MAX_LEN - 9;
let prefix = &sanitized[..prefix_len];
let prefix = prefix.trim_end_matches('_');
format!("{}_{}", prefix, hash_suffix)
}
fn uniquify_name(base: String, used: &mut HashSet<String>) -> String {
if used.insert(base.clone()) {
return base;
}
let mut n = 2u32;
loop {
let candidate = format!("{base}_{n}");
if used.insert(candidate.clone()) {
return candidate;
}
n = n.saturating_add(1);
if n == u32::MAX {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
base.hash(&mut hasher);
used.len().hash(&mut hasher);
let fallback = format!("{base}_{:08x}", hasher.finish() as u32);
used.insert(fallback.clone());
return fallback;
}
}
}
pub fn sanitize_js_identifier(name: &str) -> String {
let mut result = String::new();
let mut chars = name.chars().peekable();
if let Some(&first) = chars.peek() {
if first.is_ascii_digit() {
result.push('_');
}
}
for ch in chars {
if ch.is_ascii_alphanumeric() || ch == '_' {
result.push(ch);
} else {
if !result.ends_with('_') {
result.push('_');
}
}
}
result = result.trim_end_matches('_').to_string();
if result.is_empty() {
result = "operation".to_string();
}
result
}
fn build_template_data(&self) -> Result<K6ScriptTemplateData> {
let stages = self
.config
.scenario
.generate_stages(self.config.duration_secs, self.config.max_vus);
let base_path = self.config.base_path.as_deref().unwrap_or("");
let mut all_placeholders: HashSet<DynamicPlaceholder> = HashSet::new();
let mut used_js_names: HashSet<String> = HashSet::new();
let mut used_metric_names: HashSet<String> = HashSet::new();
let mut operations = Vec::with_capacity(self.templates.len());
for (idx, template) in self.templates.iter().enumerate() {
let display_name = template.operation.display_name();
let sanitized_name = Self::uniquify_name(
Self::sanitize_js_identifier(&display_name),
&mut used_js_names,
);
let metric_name = Self::uniquify_name(
Self::sanitize_k6_metric_name(&display_name),
&mut used_metric_names,
);
let k6_method = match template.operation.method.to_lowercase().as_str() {
"delete" => "del".to_string(),
m => m.to_string(),
};
let is_get_or_head = matches!(k6_method.as_str(), "get" | "head");
let raw_path = template.generate_path();
let full_path = join_base_path(base_path, &raw_path);
let processed_path = DynamicParamProcessor::process_path(&full_path);
all_placeholders.extend(processed_path.placeholders.clone());
let (body_value, body_is_dynamic) = if let Some(body) = &template.body {
let processed_body = DynamicParamProcessor::process_json_body(body);
all_placeholders.extend(processed_body.placeholders.clone());
(Some(processed_body.value), processed_body.is_dynamic)
} else {
(None, false)
};
let path_value = if processed_path.is_dynamic {
processed_path.value
} else {
serde_json::to_string(&full_path).unwrap_or_else(|_| "\"/\"".to_string())
};
operations.push(K6OperationData {
index: idx,
name: sanitized_name,
metric_name,
display_name,
method: k6_method,
path: Value::String(path_value),
path_is_dynamic: processed_path.is_dynamic,
headers: Value::String(self.build_headers_json(template)),
body: body_value.map(Value::String),
body_is_dynamic,
has_body: template.body.is_some(),
is_get_or_head,
});
}
let required_imports: Vec<String> =
DynamicParamProcessor::get_required_imports(&all_placeholders)
.into_iter()
.map(String::from)
.collect();
let required_globals: Vec<String> =
DynamicParamProcessor::get_required_globals(&all_placeholders)
.into_iter()
.map(String::from)
.collect();
let has_dynamic_values = !all_placeholders.is_empty();
Ok(K6ScriptTemplateData {
base_url: self.config.target_url.clone(),
stages: stages
.iter()
.map(|s| K6StageData {
duration: s.duration.clone(),
target: s.target,
})
.collect(),
operations,
threshold_percentile: self.config.threshold_percentile.clone(),
threshold_ms: self.config.threshold_ms,
max_error_rate: self.config.max_error_rate,
abort_on_error: self.abort_on_error,
abort_on_error_rate: self.abort_on_error_rate,
scenario_name: format!("{:?}", self.config.scenario).to_lowercase(),
skip_tls_verify: self.config.skip_tls_verify,
has_dynamic_values,
dynamic_imports: required_imports,
dynamic_globals: required_globals,
security_testing_enabled: self.config.security_testing_enabled,
has_custom_headers: !self.config.custom_headers.is_empty(),
chunked_request_bodies: self.config.chunked_request_bodies,
target_rps: self.config.target_rps,
no_keep_alive: self.config.no_keep_alive,
duration_secs: self.config.duration_secs,
max_vus: self.config.max_vus,
start_vus: match self.config.scenario {
LoadScenario::Constant => self.config.max_vus,
_ => 0,
},
geo_source_ips: self.config.geo_source_ips.clone(),
geo_source_headers: self.config.geo_source_headers.clone(),
has_geo_source: !self.config.geo_source_ips.is_empty()
&& !self.config.geo_source_headers.is_empty(),
geo_source_ips_json: serde_json::to_string(&self.config.geo_source_ips)
.unwrap_or_else(|_| "[]".to_string()),
geo_source_headers_json: serde_json::to_string(&self.config.geo_source_headers)
.unwrap_or_else(|_| "[]".to_string()),
force_http1: self.should_force_http1(),
})
}
fn build_headers_json(&self, template: &RequestTemplate) -> String {
let mut headers = template.get_headers();
if let Some(auth) = &self.config.auth_header {
headers.insert("Authorization".to_string(), auth.clone());
}
for (key, value) in &self.config.custom_headers {
headers.insert(key.clone(), value.clone());
}
if self.config.chunked_request_bodies && template.body.is_some() {
headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
}
serde_json::to_string(&headers).unwrap_or_else(|_| "{}".to_string())
}
pub fn validate_script(script: &str) -> Vec<String> {
let mut errors = Vec::new();
if !script.contains("import http from 'k6/http'") {
errors.push("Missing required import: 'k6/http'".to_string());
}
if !script.contains("import { check") && !script.contains("import {check") {
errors.push("Missing required import: 'check' from 'k6'".to_string());
}
if !script.contains("import { Rate, Trend") && !script.contains("import {Rate, Trend") {
errors.push("Missing required import: 'Rate, Trend' from 'k6/metrics'".to_string());
}
let lines: Vec<&str> = script.lines().collect();
let mut seen_metric_consts: HashSet<String> = HashSet::new();
for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.contains("new Trend(") || trimmed.contains("new Rate(") {
if let Some(name) = trimmed
.strip_prefix("const ")
.and_then(|rest| rest.split('=').next())
.map(str::trim)
.filter(|n| {
!n.is_empty() && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
{
if !seen_metric_consts.insert(name.to_string()) {
errors.push(format!(
"Line {}: duplicate const '{name}'. k6 exits 107 (ScriptException) when two traffic cases sanitize to the same identifier.",
line_num + 1
));
}
}
if let Some(start) = trimmed.find('\'') {
if let Some(end) = trimmed[start + 1..].find('\'') {
let metric_name = &trimmed[start + 1..start + 1 + end];
if !Self::is_valid_k6_metric_name(metric_name) {
errors.push(format!(
"Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
line_num + 1,
metric_name
));
}
}
} else if let Some(start) = trimmed.find('"') {
if let Some(end) = trimmed[start + 1..].find('"') {
let metric_name = &trimmed[start + 1..start + 1 + end];
if !Self::is_valid_k6_metric_name(metric_name) {
errors.push(format!(
"Line {}: Invalid k6 metric name '{}'. Metric names must only contain ASCII letters, numbers, or underscores and start with a letter or underscore.",
line_num + 1,
metric_name
));
}
}
}
}
if !trimmed.starts_with("//") {
if let Some(col) = Self::invalid_js_hex_escape_column(trimmed) {
errors.push(format!(
"Line {}:{}: invalid JS hex escape \\x (k6 requires two hex digits). Static paths must be JSON-encoded, not dumped into a template literal.",
line_num + 1,
col + 1
));
}
}
if trimmed.starts_with("const ") || trimmed.starts_with("let ") {
if let Some(equals_pos) = trimmed.find('=') {
let var_decl = &trimmed[..equals_pos];
if var_decl.contains('.')
&& !var_decl.contains("'")
&& !var_decl.contains("\"")
&& !var_decl.trim().starts_with("//")
{
errors.push(format!(
"Line {}: Invalid JavaScript variable name with dot: {}. Variable names cannot contain dots.",
line_num + 1,
var_decl.trim()
));
}
}
}
}
errors
}
fn invalid_js_hex_escape_column(line: &str) -> Option<usize> {
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'\\' && bytes[i + 1] == b'x' {
let mut preceding = 0usize;
let mut j = i;
while j > 0 && bytes[j - 1] == b'\\' {
preceding += 1;
j -= 1;
}
if preceding.is_multiple_of(2) {
let hex_ok = i + 3 < bytes.len()
&& bytes[i + 2].is_ascii_hexdigit()
&& bytes[i + 3].is_ascii_hexdigit();
if !hex_ok {
return Some(i);
}
}
}
i += 1;
}
None
}
fn is_valid_k6_metric_name(name: &str) -> bool {
if name.is_empty() || name.len() > 128 {
return false;
}
let mut chars = name.chars();
if let Some(first) = chars.next() {
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
}
for ch in chars {
if !ch.is_ascii_alphanumeric() && ch != '_' {
return false;
}
}
true
}
}
fn join_base_path(base_path: &str, raw_path: &str) -> String {
match base_path {
"" | "/" => raw_path.to_string(),
bp => {
let bp = bp.trim_end_matches('/');
if raw_path.starts_with('/') {
format!("{}{}", bp, raw_path)
} else {
format!("{}/{}", bp, raw_path)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_base_path_does_not_double_slash() {
assert_eq!(
join_base_path(
"/",
"/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
),
"/oauth/authorize?redirect_uri=https%3A%2F%2Fevil.example%2Flanding"
);
assert_eq!(join_base_path("", "/pets"), "/pets");
assert_eq!(join_base_path("/v1", "/pets"), "/v1/pets");
assert_eq!(join_base_path("/v1/", "pets"), "/v1/pets");
}
#[test]
fn test_k6_config_creation() {
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::RampUp,
duration_secs: 60,
max_vus: 10,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
assert_eq!(config.duration_secs, 60);
assert_eq!(config.max_vus, 10);
}
#[test]
fn test_script_generator_creation() {
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let templates = vec![];
let generator = K6ScriptGenerator::new(config, templates);
assert_eq!(generator.templates.len(), 0);
}
#[test]
fn colliding_operation_titles_get_unique_const_names() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
fn tmpl(id: &str, path: &str) -> RequestTemplate {
RequestTemplate {
operation: ApiOperation {
method: "get".to_string(),
path: path.to_string(),
operation: Operation::default(),
operation_id: Some(id.to_string()),
},
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
}
}
let config = K6Config {
target_url: "https://example.test".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 5,
max_vus: 1,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(
config,
vec![
tmpl("normal request allowed", "/a"),
tmpl("normal request allowed", "/b"),
],
);
let script = generator.generate().expect("script generates");
let latency = script
.lines()
.filter(|l| l.contains("new Trend(") && l.contains("normal_request_allowed"))
.collect::<Vec<_>>();
assert_eq!(latency.len(), 2, "expected two Trend consts, got {latency:#?}");
assert!(
script.contains("const normal_request_allowed_latency = new Trend"),
"first collision keeps the base name"
);
assert!(
script.contains("const normal_request_allowed_2_latency = new Trend")
|| script.contains("const normal_request_allowed_latency_2 = new Trend"),
"second collision must be renamed, script snippet:\n{}",
latency.join("\n")
);
let errors = K6ScriptGenerator::validate_script(&script);
assert!(errors.is_empty(), "validate_script: {errors:#?}");
}
#[test]
fn werkzeug_unc_backslash_x_is_json_encoded_not_template_literal() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let path = "/static/\\\\attacker.com\\share\\x";
let template = RequestTemplate {
operation: ApiOperation {
method: "get".to_string(),
path: path.to_string(),
operation: Operation::default(),
operation_id: Some("literal UNC double-backslash path blocked".to_string()),
},
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://example.test".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 5,
max_vus: 1,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let script = K6ScriptGenerator::new(config, vec![template])
.generate()
.expect("script generates");
let encoded = serde_json::to_string(path).expect("path JSON");
assert!(
script.contains(&format!("BASE_URL + {encoded}")),
"expected BASE_URL + {encoded} in script:\n{script}"
);
assert!(
!script.contains("${BASE_URL}/static/"),
"must not dump the raw path into a template literal:\n{script}"
);
let errors = K6ScriptGenerator::validate_script(&script);
assert!(errors.is_empty(), "validate_script: {errors:#?}\n{script}");
}
#[test]
fn validate_script_flags_bare_hex_escape_in_template_literal() {
let bad = r#"
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const t_latency = new Trend('t_latency');
export default function() {
const res = http.get(`${BASE_URL}/static/\\attacker.com\share\x`);
}
"#;
let errors = K6ScriptGenerator::validate_script(bad);
assert!(
errors.iter().any(|e| e.contains("invalid JS hex escape")),
"expected hex-escape error, got {errors:#?}"
);
assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
r#"http.get(`${BASE_URL}/static/\\attacker.com\share\x`)"#
)
.is_some());
assert!(K6ScriptGenerator::invalid_js_hex_escape_column(
r#"BASE_URL + "/static/\\\\attacker.com\\share\\x""#
)
.is_none());
}
#[test]
fn test_sanitize_js_identifier() {
assert_eq!(
K6ScriptGenerator::sanitize_js_identifier("billing.subscriptions.v1"),
"billing_subscriptions_v1"
);
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("get user"), "get_user");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("123invalid"), "_123invalid");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("getUsers"), "getUsers");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test...name"), "test_name");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier(""), "operation");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("test@name#value"), "test_name_value");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.list"), "plans_list");
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("plans.create"), "plans_create");
assert_eq!(
K6ScriptGenerator::sanitize_js_identifier("plans.update-pricing-schemes"),
"plans_update_pricing_schemes"
);
assert_eq!(K6ScriptGenerator::sanitize_js_identifier("users CRUD"), "users_CRUD");
}
#[test]
fn test_sanitize_k6_metric_name_short_passthrough() {
let short = "billing_subscriptions_list";
let out = K6ScriptGenerator::sanitize_k6_metric_name(short);
assert_eq!(out, short);
assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{out}_latency")));
}
#[test]
fn test_sanitize_k6_metric_name_truncates_long_microsoft_graph_id() {
let long = "drives.drive.items.driveItem.workbook.worksheets.workbookWorksheet.\
charts.workbookChart.axes.categoryAxis.format.line.clear";
let metric = K6ScriptGenerator::sanitize_k6_metric_name(long);
assert!(
metric.len() <= K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN,
"metric base len {} exceeded cap {}",
metric.len(),
K6ScriptGenerator::K6_METRIC_NAME_BASE_MAX_LEN
);
assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_latency")));
assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_errors")));
assert!(K6ScriptGenerator::is_valid_k6_metric_name(&format!("{metric}_step99_latency")));
}
#[test]
fn test_sanitize_k6_metric_name_distinct_long_names_get_distinct_metrics() {
let prefix = "a".repeat(150);
let a = format!("{prefix}.foo");
let b = format!("{prefix}.bar");
let ma = K6ScriptGenerator::sanitize_k6_metric_name(&a);
let mb = K6ScriptGenerator::sanitize_k6_metric_name(&b);
assert_ne!(ma, mb, "distinct long names produced the same metric name");
}
#[test]
fn test_sanitize_k6_metric_name_truncated_starts_with_letter() {
let long = format!("{}123end", "x".repeat(120));
let metric = K6ScriptGenerator::sanitize_k6_metric_name(&long);
assert!(K6ScriptGenerator::is_valid_k6_metric_name(&metric));
}
#[test]
fn test_microsoft_graph_long_operation_id_passes_validation() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let long_op_id = "drives.drive.items.driveItem.workbook.worksheets.\
workbookWorksheet.charts.workbookChart.axes.categoryAxis.format.\
line.clear";
let operation = ApiOperation {
method: "post".to_string(),
path: "/drives/{drive-id}/items/{item-id}/workbook/worksheets/{worksheet-id}/charts/{chart-id}/axes/categoryAxis/format/line/clear".to_string(),
operation: Operation::default(),
operation_id: Some(long_op_id.to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: Some("/v1.0".to_string()),
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("script generates");
let errors = K6ScriptGenerator::validate_script(&script);
assert!(
errors.is_empty(),
"validate_script returned errors for long operationId: {errors:#?}"
);
}
#[test]
fn test_abort_valve_opt_out_and_rate() {
fn base_config() -> K6Config {
K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
}
}
let default_script = K6ScriptGenerator::new(base_config(), vec![])
.generate()
.expect("script generates");
assert!(
default_script.contains("abortOnFail: true") && default_script.contains("rate<0.95"),
"default script must keep the 0.95 abort valve"
);
let stress_script = K6ScriptGenerator::new(base_config(), vec![])
.with_abort_valve(false, 0.95)
.generate()
.expect("script generates");
assert!(
!stress_script.contains("abortOnFail: true"),
"--no-abort-on-error must drop the abortOnFail threshold"
);
assert!(stress_script.contains("rate<0.05"));
let tuned_script = K6ScriptGenerator::new(base_config(), vec![])
.with_abort_valve(true, 0.99)
.generate()
.expect("script generates");
assert!(
tuned_script.contains("abortOnFail: true") && tuned_script.contains("rate<0.99"),
"--abort-on-error-rate must retune the valve threshold"
);
}
#[test]
fn test_script_generation_with_dots_in_name() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/billing/subscriptions".to_string(),
operation: Operation::default(),
operation_id: Some("billing.subscriptions.v1".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("const billing_subscriptions_v1_latency"),
"Script should contain sanitized variable name for latency"
);
assert!(
script.contains("const billing_subscriptions_v1_errors"),
"Script should contain sanitized variable name for errors"
);
assert!(
!script.contains("const billing.subscriptions"),
"Script should not contain variable names with dots - this would cause 'Unexpected token .' error"
);
assert!(
script.contains("'billing_subscriptions_v1_latency'"),
"Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
);
assert!(
script.contains("'billing_subscriptions_v1_errors'"),
"Metric name strings should be sanitized (no dots) - k6 validation requires valid metric names"
);
assert!(
script.contains("billing.subscriptions.v1"),
"Script should contain original name in comments/strings for readability"
);
assert!(
script.contains("billing_subscriptions_v1_latency.add"),
"Variable usage should use sanitized name"
);
assert!(
script.contains("billing_subscriptions_v1_errors.add"),
"Variable usage should use sanitized name"
);
}
#[test]
fn test_rps_with_ramp_up_uses_full_vu_pool_and_duration() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/users".to_string(),
operation: Operation::default(),
operation_id: Some("listUsers".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::RampUp,
duration_secs: 600,
max_vus: 100,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: Some(100),
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("constant-arrival-rate"),
"with --rps set, executor must switch to constant-arrival-rate"
);
assert!(
script.contains("rate: 100,"),
"constant-arrival-rate must use the configured --rps as `rate`"
);
assert!(
script.contains("duration: '600s'"),
"duration must come from --duration, not the ramp-down stage; got:\n{}",
script
);
assert!(
script.contains("preAllocatedVUs: 100,"),
"preAllocatedVUs must equal --vus, not the last stage's target=0; got:\n{}",
script
);
assert!(
script.contains("maxVUs: 100,"),
"maxVUs must equal --vus, not the last stage's target=0; got:\n{}",
script
);
for (idx, line) in script.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with("/*") {
continue;
}
assert!(
!trimmed.starts_with("preAllocatedVUs: 0"),
"regression at line {}: preAllocatedVUs is 0 — constant-arrival-rate \
will run no VUs (issue #79 round 5 ramp-up bug). Line: {:?}",
idx + 1,
line,
);
}
}
#[test]
fn test_cps_sets_no_connection_reuse() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/u".to_string(),
operation: Operation::default(),
operation_id: Some("u".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: true,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
assert!(
script.contains("noConnectionReuse: true"),
"--cps must set noConnectionReuse: true on the k6 options block"
);
assert!(
script.contains("Total Connections:"),
"--cps summary must include connection-rate output (Srikanth's round-5 ask)"
);
assert!(
script.contains("Connection Rate:"),
"--cps summary must include 'Connection Rate:' (Srikanth's round-5 ask)"
);
}
#[test]
fn test_constant_scenario_starts_at_target_vus() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/u".to_string(),
operation: Operation::default(),
operation_id: Some("u".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 600,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
assert!(
script.contains("startVUs: 5,"),
"--scenario constant must seed startVUs at max_vus, not 0; got:\n{}",
script
);
let ramp_config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::RampUp,
duration_secs: 600,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let ramp_template = RequestTemplate {
operation: ApiOperation {
method: "get".to_string(),
path: "/u".to_string(),
operation: Operation::default(),
operation_id: Some("u".to_string()),
},
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let ramp_script =
K6ScriptGenerator::new(ramp_config, vec![ramp_template]).generate().unwrap();
assert!(
ramp_script.contains("startVUs: 0,"),
"--scenario ramp-up must keep startVUs at 0 so stages drive the ramp; got:\n{}",
ramp_script
);
}
#[test]
fn test_connections_opened_counter_present() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/u".to_string(),
operation: Operation::default(),
operation_id: Some("u".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: Some(50),
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let script = K6ScriptGenerator::new(config, vec![template]).generate().unwrap();
assert!(
script.contains("new Counter('mockforge_connections_opened')"),
"template must declare the mockforge_connections_opened Counter"
);
assert!(
script.contains("mockforge_connections_opened.add(1)"),
"template must increment mockforge_connections_opened on new TCP connect"
);
assert!(
script.contains("res.timings.connecting > 0"),
"template must gate the connection-opened increment on \
res.timings.connecting > 0 (only fires when a fresh socket was opened)"
);
}
#[test]
fn test_validate_script_valid() {
let valid_script = r#"
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const test_latency = new Trend('test_latency');
const test_errors = new Rate('test_errors');
export default function() {
const res = http.get('https://example.com');
test_latency.add(res.timings.duration);
test_errors.add(res.status !== 200);
}
"#;
let errors = K6ScriptGenerator::validate_script(valid_script);
assert!(errors.is_empty(), "Valid script should have no validation errors");
}
#[test]
fn test_validate_script_invalid_metric_name() {
let invalid_script = r#"
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const test_latency = new Trend('test.latency');
const test_errors = new Rate('test_errors');
export default function() {
const res = http.get('https://example.com');
test_latency.add(res.timings.duration);
}
"#;
let errors = K6ScriptGenerator::validate_script(invalid_script);
assert!(
!errors.is_empty(),
"Script with invalid metric name should have validation errors"
);
assert!(
errors.iter().any(|e| e.contains("Invalid k6 metric name")),
"Should detect invalid metric name with dot"
);
}
#[test]
fn test_validate_script_missing_imports() {
let invalid_script = r#"
const test_latency = new Trend('test_latency');
export default function() {}
"#;
let errors = K6ScriptGenerator::validate_script(invalid_script);
assert!(!errors.is_empty(), "Script missing imports should have validation errors");
}
#[test]
fn test_validate_script_metric_name_validation() {
let valid_script = r#"
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const test_latency = new Trend('test_latency');
const test_errors = new Rate('test_errors');
export default function() {}
"#;
let errors = K6ScriptGenerator::validate_script(valid_script);
assert!(errors.is_empty(), "Valid metric names should pass validation");
let invalid_cases = vec![
("test.latency", "dot in metric name"),
("123test", "starts with number"),
("test-latency", "hyphen in metric name"),
("test@latency", "special character"),
];
for (invalid_name, description) in invalid_cases {
let script = format!(
r#"
import http from 'k6/http';
import {{ check, sleep }} from 'k6';
import {{ Rate, Trend }} from 'k6/metrics';
const test_latency = new Trend('{}');
export default function() {{}}
"#,
invalid_name
);
let errors = K6ScriptGenerator::validate_script(&script);
assert!(
!errors.is_empty(),
"Metric name '{}' ({}) should fail validation",
invalid_name,
description
);
}
}
#[test]
fn test_skip_tls_verify_with_body() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("createUser".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({"name": "test"})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: true,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("insecureSkipTLSVerify: true"),
"Script should include insecureSkipTLSVerify option when skip_tls_verify is true"
);
}
#[test]
fn test_skip_tls_verify_without_body() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("getUsers".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: true,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("insecureSkipTLSVerify: true"),
"Script should include insecureSkipTLSVerify option when skip_tls_verify is true (no body)"
);
}
#[test]
fn test_no_skip_tls_verify() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("getUsers".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
!script.contains("insecureSkipTLSVerify"),
"Script should NOT include insecureSkipTLSVerify option when skip_tls_verify is false"
);
}
#[test]
fn test_skip_tls_verify_multiple_operations() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation1 = ApiOperation {
method: "get".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("getUsers".to_string()),
};
let operation2 = ApiOperation {
method: "post".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("createUser".to_string()),
};
let template1 = RequestTemplate {
operation: operation1,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let template2 = RequestTemplate {
operation: operation2,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({"name": "test"})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: true,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template1, template2]);
let script = generator.generate().expect("Should generate script");
let skip_count = script.matches("insecureSkipTLSVerify: true").count();
assert_eq!(
skip_count, 1,
"Script should include insecureSkipTLSVerify exactly once in global options (not per-request)"
);
let options_start = script.find("export const options = {").expect("Should have options");
let scenarios_start = script.find("scenarios:").expect("Should have scenarios");
let options_prefix = &script[options_start..scenarios_start];
assert!(
options_prefix.contains("insecureSkipTLSVerify: true"),
"insecureSkipTLSVerify should be in global options block"
);
}
#[test]
fn test_dynamic_params_in_body() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/resources".to_string(),
operation: Operation::default(),
operation_id: Some("createResource".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({
"name": "load-test-${__VU}",
"iteration": "${__ITER}"
})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("Dynamic body with runtime placeholders"),
"Script should contain comment about dynamic body"
);
assert!(
script.contains("__VU"),
"Script should contain __VU reference for dynamic VU-based values"
);
assert!(
script.contains("__ITER"),
"Script should contain __ITER reference for dynamic iteration values"
);
}
#[test]
fn test_dynamic_params_with_uuid() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/resources".to_string(),
operation: Operation::default(),
operation_id: Some("createResource".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({
"id": "${__UUID}"
})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
!script.contains("k6/experimental/webcrypto"),
"Script should NOT include deprecated k6/experimental/webcrypto import"
);
assert!(
script.contains("crypto.randomUUID()"),
"Script should contain crypto.randomUUID() for UUID placeholder"
);
}
#[test]
fn test_dynamic_params_with_counter() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/resources".to_string(),
operation: Operation::default(),
operation_id: Some("createResource".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({
"sequence": "${__COUNTER}"
})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("let globalCounter = 0"),
"Script should include globalCounter initialization when COUNTER placeholder is used"
);
assert!(
script.contains("globalCounter++"),
"Script should contain globalCounter++ for COUNTER placeholder"
);
}
#[test]
fn test_static_body_no_dynamic_marker() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/resources".to_string(),
operation: Operation::default(),
operation_id: Some("createResource".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({
"name": "static-value",
"count": 42
})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
!script.contains("Dynamic body with runtime placeholders"),
"Script should NOT contain dynamic body comment for static body"
);
assert!(
!script.contains("webcrypto"),
"Script should NOT include webcrypto import for static body"
);
assert!(
!script.contains("let globalCounter"),
"Script should NOT include globalCounter for static body"
);
}
#[test]
fn test_security_testing_enabled_generates_calling_code() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("createUser".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({"name": "test"})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: true,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("getNextSecurityPayload"),
"Script should contain getNextSecurityPayload() call when security_testing_enabled is true"
);
assert!(
script.contains("applySecurityPayload"),
"Script should contain applySecurityPayload() call when security_testing_enabled is true"
);
assert!(
script.contains("secPayloadGroup"),
"Script should contain secPayloadGroup variable when security_testing_enabled is true"
);
assert!(
script.contains("secBodyPayload"),
"Script should contain secBodyPayload variable when security_testing_enabled is true"
);
assert!(
script.contains("hasSecCookie"),
"Script should track hasSecCookie for CookieJar conflict avoidance"
);
assert!(
script.contains("secRequestOpts"),
"Script should use secRequestOpts to conditionally skip CookieJar"
);
assert!(
script.contains("const requestHeaders = { ..."),
"Script should spread headers into mutable copy for security payload injection"
);
assert!(
script.contains("secPayload.injectAsPath"),
"Script should check injectAsPath for path-based URI injection"
);
assert!(
script.contains("secBodyPayload.formBody"),
"Script should check formBody for form-encoded body delivery"
);
assert!(
script.contains("application/x-www-form-urlencoded"),
"Script should set Content-Type for form-encoded body"
);
let op_comment_pos =
script.find("// Operation 0:").expect("Should have Operation 0 comment");
let sec_payload_pos = script
.find("const secPayloadGroup = typeof getNextSecurityPayload")
.expect("Should have secPayloadGroup assignment");
assert!(
sec_payload_pos > op_comment_pos,
"secPayloadGroup should be fetched inside operation block (per-operation), not before it (per-iteration)"
);
}
#[test]
fn test_security_testing_disabled_no_calling_code() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("createUser".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({"name": "test"})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
!script.contains("getNextSecurityPayload"),
"Script should NOT contain getNextSecurityPayload() when security_testing_enabled is false"
);
assert!(
!script.contains("applySecurityPayload"),
"Script should NOT contain applySecurityPayload() when security_testing_enabled is false"
);
assert!(
!script.contains("secPayloadGroup"),
"Script should NOT contain secPayloadGroup variable when security_testing_enabled is false"
);
assert!(
!script.contains("secBodyPayload"),
"Script should NOT contain secBodyPayload variable when security_testing_enabled is false"
);
assert!(
!script.contains("hasSecCookie"),
"Script should NOT contain hasSecCookie when security_testing_enabled is false"
);
assert!(
!script.contains("secRequestOpts"),
"Script should NOT contain secRequestOpts when security_testing_enabled is false"
);
assert!(
!script.contains("injectAsPath"),
"Script should NOT contain injectAsPath when security_testing_enabled is false"
);
assert!(
!script.contains("formBody"),
"Script should NOT contain formBody when security_testing_enabled is false"
);
}
#[test]
fn test_security_e2e_definitions_and_calls_both_present() {
use crate::security_payloads::{
SecurityPayloads, SecurityTestConfig, SecurityTestGenerator,
};
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("createUser".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({"name": "test"})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: true,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let mut script = generator.generate().expect("Should generate base script");
let security_config = SecurityTestConfig::default().enable();
let payloads = SecurityPayloads::get_payloads(&security_config);
assert!(!payloads.is_empty(), "Should have built-in payloads");
let mut additional_code = String::new();
additional_code
.push_str(&SecurityTestGenerator::generate_payload_selection(&payloads, false));
additional_code.push('\n');
additional_code.push_str(&SecurityTestGenerator::generate_apply_payload(&[]));
additional_code.push('\n');
if let Some(pos) = script.find("export const options") {
script.insert_str(
pos,
&format!("\n// === Advanced Testing Features ===\n{}\n", additional_code),
);
}
assert!(
script.contains("function getNextSecurityPayload()"),
"Final script must contain getNextSecurityPayload function DEFINITION"
);
assert!(
script.contains("function applySecurityPayload("),
"Final script must contain applySecurityPayload function DEFINITION"
);
assert!(
script.contains("securityPayloads"),
"Final script must contain securityPayloads array"
);
assert!(
script.contains("const secPayloadGroup = typeof getNextSecurityPayload"),
"Final script must contain secPayloadGroup assignment (template calling code)"
);
assert!(
script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
"Final script must contain applySecurityPayload CALL with secBodyPayload"
);
assert!(
script.contains("const requestHeaders = { ..."),
"Final script must spread headers for security payload header injection"
);
assert!(
script.contains("for (const secPayload of secPayloadGroup)"),
"Final script must loop over secPayloadGroup"
);
assert!(
script.contains("secPayload.injectAsPath"),
"Final script must check injectAsPath for path-based URI injection"
);
assert!(
script.contains("secBodyPayload.formBody"),
"Final script must check formBody for form-encoded body delivery"
);
let def_pos = script.find("function getNextSecurityPayload()").unwrap();
let call_pos =
script.find("const secPayloadGroup = typeof getNextSecurityPayload").unwrap();
let options_pos = script.find("export const options").unwrap();
let default_fn_pos = script.find("export default function").unwrap();
assert!(
def_pos < options_pos,
"Function definitions must appear before export const options"
);
assert!(
call_pos > default_fn_pos,
"Calling code must appear inside export default function"
);
}
#[test]
fn test_security_uri_injection_for_get_requests() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("listUsers".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: true,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("requestUrl"),
"Script should build requestUrl variable for URI payload injection"
);
assert!(
script.contains("secPayload.location === 'uri'"),
"Script should check for URI-location payloads"
);
assert!(
script.contains("'test=' + encodeURIComponent(secPayload.payload)"),
"Script should URL-encode security payload in query string for valid HTTP"
);
assert!(
script.contains("secPayload.injectAsPath"),
"Script should check injectAsPath for path-based URI injection"
);
assert!(
script.contains("encodeURI(secPayload.payload)"),
"Script should use encodeURI for path-based injection"
);
assert!(
script.contains("http.get(requestUrl,"),
"GET request should use requestUrl (with URI injection) instead of inline URL"
);
}
#[test]
fn test_security_uri_injection_for_post_requests() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
use serde_json::json;
let operation = ApiOperation {
method: "post".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("createUser".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: Some(json!({"name": "test"})),
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: true,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("requestUrl"),
"POST script should build requestUrl for URI payload injection"
);
assert!(
script.contains("secPayload.location === 'uri'"),
"POST script should check for URI-location payloads"
);
assert!(
script.contains("applySecurityPayload(payload, [], secBodyPayload)"),
"POST script should apply security body payload to request body"
);
assert!(
script.contains("http.post(requestUrl,"),
"POST request should use requestUrl (with URI injection) instead of inline URL"
);
}
#[test]
fn test_no_uri_injection_when_security_disabled() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("listUsers".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
!script.contains("requestUrl"),
"Script should NOT have requestUrl when security is disabled"
);
assert!(
!script.contains("secPayloadGroup"),
"Script should NOT have secPayloadGroup when security is disabled"
);
assert!(
!script.contains("secBodyPayload"),
"Script should NOT have secBodyPayload when security is disabled"
);
}
#[test]
fn test_uses_per_request_cookie_jar() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/api/users".to_string(),
operation: Operation::default(),
operation_id: Some("listUsers".to_string()),
};
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers: HashMap::new(),
body: None,
};
let config = K6Config {
target_url: "https://api.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 5,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: false,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
let script = generator.generate().expect("Should generate script");
assert!(
script.contains("jar: new http.CookieJar()"),
"Script should create fresh CookieJar per request"
);
assert!(
!script.contains("jar: null"),
"Script should NOT use jar: null (does not disable default VU cookie jar in k6)"
);
assert!(
!script.contains("EMPTY_JAR"),
"Script should NOT use shared EMPTY_JAR (accumulates Set-Cookie responses)"
);
}
#[test]
fn connection_header_forces_http1_comment_and_stays_on_the_wire() {
use crate::spec_parser::ApiOperation;
use openapiv3::Operation;
let operation = ApiOperation {
method: "get".to_string(),
path: "/hop".to_string(),
operation: Operation::default(),
operation_id: Some("hop".to_string()),
};
let mut headers = HashMap::new();
headers.insert("Connection".to_string(), "Transfer-Encoding, keep-alive".to_string());
let template = RequestTemplate {
operation,
path_params: HashMap::new(),
query_params: HashMap::new(),
headers,
body: None,
};
let config = K6Config {
target_url: "https://waf.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 1,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: true,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![template]);
assert!(generator.should_force_http1());
let data = generator.build_template_data().expect("template data");
assert!(data.force_http1);
let script = generator.generate().expect("script generates");
assert!(
script.contains("GODEBUG=http2client=0"),
"script must tell a manual k6 run to disable HTTP/2"
);
assert!(
script.contains("Transfer-Encoding, keep-alive"),
"Connection value must stay in the script; stripping it skips the WAF case"
);
assert!(
script.contains("\"Connection\"") || script.contains("Connection"),
"Connection header key must stay on the wire"
);
}
#[test]
fn verbatim_flag_forces_http1_comment_without_connection_header() {
let config = K6Config {
target_url: "https://waf.example.com".to_string(),
base_path: None,
scenario: LoadScenario::Constant,
duration_secs: 30,
max_vus: 1,
threshold_percentile: "p(95)".to_string(),
threshold_ms: 500,
max_error_rate: 0.05,
auth_header: None,
custom_headers: HashMap::new(),
skip_tls_verify: true,
security_testing_enabled: false,
chunked_request_bodies: false,
target_rps: None,
no_keep_alive: false,
geo_source_ips: Vec::new(),
geo_source_headers: Vec::new(),
};
let generator = K6ScriptGenerator::new(config, vec![]).with_force_http1(true);
assert!(generator.should_force_http1());
let script = generator.generate().expect("script generates");
assert!(script.contains("GODEBUG=http2client=0"));
}
}