Skip to main content

mockforge_bench/conformance/
generator.rs

1//! k6 script generator for OpenAPI 3.0.0 conformance testing
2
3use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8/// Configuration for conformance test generation
9#[derive(Default, Clone)]
10pub struct ConformanceConfig {
11    /// Target base URL
12    pub target_url: String,
13    /// API key for security scheme tests
14    pub api_key: Option<String>,
15    /// Basic auth credentials (user:pass) for security scheme tests
16    pub basic_auth: Option<String>,
17    /// Skip TLS verification
18    pub skip_tls_verify: bool,
19    /// Optional category filter — None means all categories
20    pub categories: Option<Vec<String>>,
21    /// Optional base path prefix for all generated URLs (e.g., "/api")
22    pub base_path: Option<String>,
23    /// Custom headers to inject into every conformance request (e.g., auth headers).
24    /// Each entry is (header_name, header_value). When a custom header matches
25    /// a spec-derived header name, the custom value replaces the placeholder.
26    pub custom_headers: Vec<(String, String)>,
27    /// Output directory for the conformance report (absolute path).
28    /// Used to write `conformance-report.json` to a deterministic location
29    /// so the CLI can find it after k6 execution.
30    pub output_dir: Option<PathBuf>,
31    /// When true, test ALL operations for method/response/body categories
32    /// instead of just one representative per feature check name.
33    pub all_operations: bool,
34    /// Optional path to a YAML file with custom conformance checks
35    pub custom_checks_file: Option<PathBuf>,
36    /// Delay in milliseconds between consecutive conformance requests.
37    /// Useful when testing against rate-limited APIs. Default: 0 (no delay).
38    pub request_delay_ms: u64,
39    /// Optional regex to filter custom checks by name or path.
40    /// Only checks whose name or path matches the regex are included.
41    pub custom_filter: Option<String>,
42    /// When true, export all request/response pairs to a JSON file
43    /// in the output directory (`conformance-requests.json`).
44    pub export_requests: bool,
45    /// When true, validate each request against the OpenAPI spec before
46    /// sending and report violations to `conformance-request-violations.json`.
47    pub validate_requests: bool,
48}
49
50impl ConformanceConfig {
51    /// Check if a category should be included based on the filter
52    pub fn should_include_category(&self, category: &str) -> bool {
53        match &self.categories {
54            None => true,
55            Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
56        }
57    }
58
59    /// Returns true if custom headers are configured
60    pub fn has_custom_headers(&self) -> bool {
61        !self.custom_headers.is_empty()
62    }
63
64    /// Returns true if custom headers contain a Cookie header.
65    /// When true, k6's automatic cookie jar should be disabled to prevent
66    /// duplicate cookies on subsequent requests.
67    pub fn has_cookie_header(&self) -> bool {
68        self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
69    }
70
71    /// Format custom headers as a JS object literal string
72    pub fn custom_headers_js_object(&self) -> String {
73        let entries: Vec<String> = self
74            .custom_headers
75            .iter()
76            .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
77            .collect();
78        format!("{{ {} }}", entries.join(", "))
79    }
80
81    /// Generate a k6 group block for custom checks, if configured.
82    /// Returns `Ok(None)` if no custom checks file is configured.
83    /// Respects `custom_filter` to include only matching checks.
84    ///
85    /// Round 39 (#79) — returns BOTH the init-scope code (e.g.
86    /// `const __file_0 = open('/path', 'b')` for file uploads) and
87    /// the group body. The caller splices `init_code` near the top of
88    /// the script (after `BASE_URL`, before `export default
89    /// function`) and `group_body` inside the default function. k6
90    /// requires `open()` to live at init scope.
91    pub fn generate_custom_group(
92        &self,
93    ) -> Result<Option<crate::conformance::custom::K6CustomEmit>> {
94        let path = match &self.custom_checks_file {
95            Some(p) => p,
96            None => return Ok(None),
97        };
98        let mut config = CustomConformanceConfig::from_file(path)?;
99        if config.custom_checks.is_empty() {
100            return Ok(None);
101        }
102
103        // Apply regex filter if provided
104        if let Some(ref pattern) = self.custom_filter {
105            let re = regex::Regex::new(pattern).map_err(|e| {
106                BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
107            })?;
108            let total = config.custom_checks.len();
109            config.custom_checks.retain(|c| re.is_match(&c.name) || re.is_match(&c.path));
110            tracing::info!(
111                "Custom check filter: {}/{} checks matched pattern",
112                config.custom_checks.len(),
113                total
114            );
115            if config.custom_checks.is_empty() {
116                return Ok(None);
117            }
118        }
119
120        Ok(Some(config.emit_k6_with_options(
121            "BASE_URL",
122            &self.custom_headers,
123            self.export_requests,
124        )))
125    }
126
127    /// Returns the effective base URL with base_path appended.
128    /// Handles trailing/leading slash normalization to avoid double slashes.
129    /// Always trims trailing slashes from the result so that `${BASE_URL}/path`
130    /// never produces `//path`.
131    pub fn effective_base_url(&self) -> String {
132        let base = match &self.base_path {
133            None => self.target_url.trim_end_matches('/').to_string(),
134            Some(bp) if bp.is_empty() => self.target_url.trim_end_matches('/').to_string(),
135            Some(bp) => {
136                let url = self.target_url.trim_end_matches('/');
137                let path = if bp.starts_with('/') {
138                    bp.as_str()
139                } else {
140                    return format!("{}/{}", url, bp).trim_end_matches('/').to_string();
141                };
142                format!("{}{}", url, path).trim_end_matches('/').to_string()
143            }
144        };
145        base
146    }
147}
148
149/// Generates k6 scripts for OpenAPI 3.0.0 conformance testing
150pub struct ConformanceGenerator {
151    config: ConformanceConfig,
152}
153
154impl ConformanceGenerator {
155    pub fn new(config: ConformanceConfig) -> Self {
156        Self { config }
157    }
158
159    /// Generate the conformance test k6 script
160    pub fn generate(&self) -> Result<String> {
161        let mut script = String::with_capacity(16384);
162
163        // Imports
164        script.push_str("import http from 'k6/http';\n");
165        script.push_str("import { check, group } from 'k6';\n");
166        if self.config.request_delay_ms > 0 {
167            script.push_str("import { sleep } from 'k6';\n");
168        }
169        script.push('\n');
170
171        // Tell k6 that all HTTP status codes are "expected" in conformance mode.
172        // Without this, k6 counts 4xx responses (e.g. intentional 404 tests) as
173        // http_req_failed errors, producing a misleading error rate percentage.
174        script.push_str(
175            "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
176        );
177
178        // Options: 1 VU, 1 iteration (functional test, not load test)
179        script.push_str("export const options = {\n");
180        script.push_str("  vus: 1,\n");
181        script.push_str("  iterations: 1,\n");
182        if self.config.skip_tls_verify {
183            script.push_str("  insecureSkipTLSVerify: true,\n");
184        }
185        script.push_str("  thresholds: {\n");
186        script.push_str("    checks: ['rate>0'],\n");
187        script.push_str("  },\n");
188        script.push_str("};\n\n");
189
190        // Base URL (includes base_path if configured)
191        script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
192
193        // Delay between requests (seconds) to avoid rate limiting
194        if self.config.request_delay_ms > 0 {
195            script.push_str(&format!(
196                "const REQUEST_DELAY = {:.3};\n\n",
197                self.config.request_delay_ms as f64 / 1000.0
198            ));
199        }
200
201        // Helper: JSON headers
202        script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
203
204        // Round 39 (#79) — emit init-scope code (e.g. `open()` calls
205        // for file uploads in custom checks) here, before any
206        // function declarations. k6 requires `open()` to live at
207        // script init scope; placing it inside `export default
208        // function` is a runtime ReferenceError.
209        let custom_emit = self.config.generate_custom_group()?;
210        if let Some(emit) = &custom_emit {
211            if !emit.init_code.is_empty() {
212                script.push_str("// Round 39 (#79) — preloaded upload bytes for custom checks\n");
213                script.push_str(&emit.init_code);
214                script.push('\n');
215            }
216        }
217
218        // Failure detail collector — logs req/res info for failed checks via console.log
219        script.push_str("function __captureFailure(checkName, res, expected) {\n");
220        script.push_str("  let bodyStr = '';\n");
221        script.push_str("  try { bodyStr = res.body ? res.body.substring(0, 2000) : ''; } catch(e) { bodyStr = '<unreadable>'; }\n");
222        script.push_str("  let reqHeaders = {};\n");
223        script.push_str(
224            "  if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
225        );
226        script.push_str("  let reqBody = '';\n");
227        script.push_str("  if (res.request && res.request.body) { try { reqBody = res.request.body.substring(0, 2000); } catch(e) {} }\n");
228        script.push_str("  console.log('MOCKFORGE_FAILURE:' + JSON.stringify({\n");
229        script.push_str("    check: checkName,\n");
230        script.push_str("    request: {\n");
231        script.push_str("      method: res.request ? res.request.method : 'unknown',\n");
232        script.push_str("      url: res.request ? res.request.url : res.url || 'unknown',\n");
233        script.push_str("      headers: reqHeaders,\n");
234        script.push_str("      body: reqBody,\n");
235        script.push_str("    },\n");
236        script.push_str("    response: {\n");
237        script.push_str("      status: res.status,\n");
238        script.push_str("      headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 20)) : {},\n");
239        script.push_str("      body: bodyStr,\n");
240        script.push_str("    },\n");
241        script.push_str("    expected: expected,\n");
242        script.push_str("  }));\n");
243        script.push_str("}\n\n");
244
245        // Request/response capture for --export-requests (uses console.log since
246        // k6's handleSummary runs in a separate JS context with no access to
247        // module-level variables — the CLI parses the output log after k6 exits).
248        if self.config.export_requests {
249            script.push_str("function __captureExchange(checkName, res) {\n");
250            script.push_str("  let bodyStr = '';\n");
251            script.push_str("  try { bodyStr = res.body ? res.body.substring(0, 2000) : ''; } catch(e) { bodyStr = '<unreadable>'; }\n");
252            script.push_str("  let reqHeaders = {};\n");
253            script.push_str(
254                "  if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
255            );
256            // Round 41 (#79) — Srikanth on 0.3.185: "When run without
257            // Spec the export request file has blank entry". k6's
258            // `res.request.body` is empty for multipart uploads (k6
259            // serialises the form internally and the JS-side body
260            // string is null). Fall back to a content-type-derived
261            // summary so the export at least surfaces "multipart/form-data; N parts"
262            // instead of an empty string. Real bodies still surface
263            // unchanged.
264            script.push_str("  let reqBody = '';\n");
265            script.push_str("  if (res.request && res.request.body) { try { reqBody = res.request.body.substring(0, 2000); } catch(e) {} }\n");
266            script.push_str(
267                "  if (!reqBody) {\n\
268                 \x20\x20\x20\x20const ct = (reqHeaders['Content-Type'] || reqHeaders['content-type'] || '').toString();\n\
269                 \x20\x20\x20\x20if (ct.startsWith('multipart/')) reqBody = '<multipart upload; body bytes not surfaced by k6 res.request.body>';\n\
270                 \x20\x20}\n",
271            );
272            script.push_str("  console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
273            script.push_str("    check: checkName,\n");
274            script.push_str("    request: {\n");
275            script.push_str("      method: res.request ? res.request.method : 'unknown',\n");
276            script.push_str("      url: res.request ? res.request.url : res.url || 'unknown',\n");
277            script.push_str("      headers: reqHeaders,\n");
278            script.push_str("      body: reqBody,\n");
279            script.push_str("    },\n");
280            script.push_str("    response: {\n");
281            script.push_str("      status: res.status,\n");
282            script.push_str("      headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
283            script.push_str("      body: bodyStr,\n");
284            script.push_str("    },\n");
285            script.push_str("  }));\n");
286            script.push_str("}\n\n");
287        }
288
289        // Default function
290        script.push_str("export default function () {\n");
291
292        if self.config.has_cookie_header() {
293            script.push_str(
294                "  // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
295            );
296            script.push_str("  http.cookieJar().clear(BASE_URL);\n\n");
297        }
298
299        // Helper to insert a delay between groups when --conformance-delay is set
300        let delay_between = if self.config.request_delay_ms > 0 {
301            "  sleep(REQUEST_DELAY);\n".to_string()
302        } else {
303            String::new()
304        };
305
306        // Round 39 (#79) — Srikanth on 0.3.183: "In the exported
307        // request I see it is sending request to
308        // api/conformance/params/hello and some other URLs". When the
309        // user passed `--conformance-custom` without `--spec`, the
310        // generator still emitted the 47 built-in reference checks
311        // against `/conformance/...` paths, which 404 on a real
312        // target. Skip them when custom checks are the only input —
313        // matching the native executor's `custom_only` branch.
314        let custom_only = self.config.custom_checks_file.is_some()
315            && !self.config.target_url.is_empty()
316            // Reference checks ARE the right answer when the user
317            // explicitly listed categories with --conformance-category.
318            && self.config.categories.is_none();
319        if !custom_only {
320            if self.config.should_include_category("Parameters") {
321                self.generate_parameters_group(&mut script);
322                script.push_str(&delay_between);
323            }
324            if self.config.should_include_category("Request Bodies") {
325                self.generate_request_bodies_group(&mut script);
326                script.push_str(&delay_between);
327            }
328            if self.config.should_include_category("Schema Types") {
329                self.generate_schema_types_group(&mut script);
330                script.push_str(&delay_between);
331            }
332            if self.config.should_include_category("Composition") {
333                self.generate_composition_group(&mut script);
334                script.push_str(&delay_between);
335            }
336            if self.config.should_include_category("String Formats") {
337                self.generate_string_formats_group(&mut script);
338                script.push_str(&delay_between);
339            }
340            if self.config.should_include_category("Constraints") {
341                self.generate_constraints_group(&mut script);
342                script.push_str(&delay_between);
343            }
344            if self.config.should_include_category("Response Codes") {
345                self.generate_response_codes_group(&mut script);
346                script.push_str(&delay_between);
347            }
348            if self.config.should_include_category("HTTP Methods") {
349                self.generate_http_methods_group(&mut script);
350                script.push_str(&delay_between);
351            }
352            if self.config.should_include_category("Content Types") {
353                self.generate_content_negotiation_group(&mut script);
354                script.push_str(&delay_between);
355            }
356            if self.config.should_include_category("Security") {
357                self.generate_security_group(&mut script);
358            }
359        }
360
361        // Custom checks from YAML file — round 39: we already called
362        // `generate_custom_group()` above to emit init-scope code, so
363        // here we just splice the group body inside the default
364        // function.
365        if let Some(emit) = custom_emit {
366            script.push_str(&emit.group_body);
367        }
368
369        script.push_str("}\n\n");
370
371        // handleSummary for conformance report output
372        self.generate_handle_summary(&mut script);
373
374        Ok(script)
375    }
376
377    /// Write the generated script to a file
378    pub fn write_script(&self, path: &Path) -> Result<()> {
379        let script = self.generate()?;
380        if let Some(parent) = path.parent() {
381            std::fs::create_dir_all(parent)?;
382        }
383        std::fs::write(path, script)
384            .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
385    }
386
387    /// Returns a JS expression for merging custom headers with provided headers.
388    /// If no custom headers, returns the input as-is.
389    /// If custom headers exist, wraps with Object.assign using inline header object.
390    fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
391        if self.config.has_custom_headers() {
392            format!(
393                "Object.assign({{}}, {}, {})",
394                headers_expr,
395                self.config.custom_headers_js_object()
396            )
397        } else {
398            headers_expr.to_string()
399        }
400    }
401
402    /// Emit a GET request with optional custom headers merged in.
403    fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
404        let has_custom = self.config.has_custom_headers();
405        let custom_obj = self.config.custom_headers_js_object();
406        match (extra_headers, has_custom) {
407            (None, false) => {
408                script.push_str(&format!("      let res = http.get(`{}`);\n", url));
409            }
410            (None, true) => {
411                script.push_str(&format!(
412                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
413                    url, custom_obj
414                ));
415            }
416            (Some(hdrs), false) => {
417                script.push_str(&format!(
418                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
419                    url, hdrs
420                ));
421            }
422            (Some(hdrs), true) => {
423                script.push_str(&format!(
424                    "      let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
425                    url, hdrs, custom_obj
426                ));
427            }
428        }
429        self.maybe_clear_cookie_jar(script);
430        self.maybe_capture_exchange(script);
431    }
432
433    /// Emit a POST/PUT/PATCH request with optional custom headers merged in.
434    fn emit_post_like(
435        &self,
436        script: &mut String,
437        method: &str,
438        url: &str,
439        body: &str,
440        headers_expr: &str,
441    ) {
442        let merged = self.merge_with_custom_headers(headers_expr);
443        script.push_str(&format!(
444            "      let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
445            method, url, body, merged
446        ));
447        self.maybe_clear_cookie_jar(script);
448        self.maybe_capture_exchange(script);
449    }
450
451    /// Emit a DELETE/HEAD/OPTIONS request with optional custom headers.
452    fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
453        if self.config.has_custom_headers() {
454            script.push_str(&format!(
455                "      let res = http.{}(`{}`, {{ headers: {} }});\n",
456                method,
457                url,
458                self.config.custom_headers_js_object()
459            ));
460        } else {
461            script.push_str(&format!("      let res = http.{}(`{}`);\n", method, url));
462        }
463        self.maybe_clear_cookie_jar(script);
464        self.maybe_capture_exchange(script);
465    }
466
467    /// Emit `__captureExchange` call when `--export-requests` is enabled.
468    fn maybe_capture_exchange(&self, script: &mut String) {
469        if self.config.export_requests {
470            script.push_str(
471                "      if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
472            );
473        }
474    }
475
476    /// Emit cookie jar clearing after a request when custom Cookie headers are used.
477    /// Prevents k6's internal cookie jar from re-sending server Set-Cookie values
478    /// alongside the custom Cookie header on subsequent requests.
479    fn maybe_clear_cookie_jar(&self, script: &mut String) {
480        if self.config.has_cookie_header() {
481            script.push_str("      http.cookieJar().clear(BASE_URL);\n");
482        }
483    }
484
485    fn generate_parameters_group(&self, script: &mut String) {
486        script.push_str("  group('Parameters', function () {\n");
487
488        // Path param: string
489        script.push_str("    {\n");
490        self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
491        script.push_str(
492            "      check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
493        );
494        script.push_str("    }\n");
495
496        // Path param: integer
497        script.push_str("    {\n");
498        self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
499        script.push_str(
500            "      check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
501        );
502        script.push_str("    }\n");
503
504        // Query param: string
505        script.push_str("    {\n");
506        self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
507        script.push_str(
508            "      check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
509        );
510        script.push_str("    }\n");
511
512        // Query param: integer
513        script.push_str("    {\n");
514        self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
515        script.push_str(
516            "      check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
517        );
518        script.push_str("    }\n");
519
520        // Query param: array
521        script.push_str("    {\n");
522        self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
523        script.push_str(
524            "      check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
525        );
526        script.push_str("    }\n");
527
528        // Header param
529        script.push_str("    {\n");
530        self.emit_get(
531            script,
532            "${BASE_URL}/conformance/params/header",
533            Some("{ 'X-Custom-Param': 'test-value' }"),
534        );
535        script.push_str(
536            "      check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
537        );
538        script.push_str("    }\n");
539
540        // Cookie param
541        script.push_str("    {\n");
542        script.push_str("      let jar = http.cookieJar();\n");
543        script.push_str("      jar.set(BASE_URL, 'session', 'abc123');\n");
544        self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
545        script.push_str(
546            "      check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
547        );
548        script.push_str("    }\n");
549
550        script.push_str("  });\n\n");
551    }
552
553    fn generate_request_bodies_group(&self, script: &mut String) {
554        script.push_str("  group('Request Bodies', function () {\n");
555
556        // JSON body
557        script.push_str("    {\n");
558        self.emit_post_like(
559            script,
560            "post",
561            "${BASE_URL}/conformance/body/json",
562            "JSON.stringify({ name: 'test', value: 42 })",
563            "JSON_HEADERS",
564        );
565        script.push_str(
566            "      check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
567        );
568        script.push_str("    }\n");
569
570        // Form-urlencoded body
571        script.push_str("    {\n");
572        if self.config.has_custom_headers() {
573            script.push_str(&format!(
574                "      let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
575                self.config.custom_headers_js_object()
576            ));
577        } else {
578            script.push_str(
579                "      let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
580            );
581        }
582        self.maybe_clear_cookie_jar(script);
583        script.push_str(
584            "      check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
585        );
586        script.push_str("    }\n");
587
588        // Multipart body
589        script.push_str("    {\n");
590        script.push_str(
591            "      let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
592        );
593        if self.config.has_custom_headers() {
594            script.push_str(&format!(
595                "      let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
596                self.config.custom_headers_js_object()
597            ));
598        } else {
599            script.push_str(
600                "      let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
601            );
602        }
603        self.maybe_clear_cookie_jar(script);
604        script.push_str(
605            "      check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
606        );
607        script.push_str("    }\n");
608
609        script.push_str("  });\n\n");
610    }
611
612    fn generate_schema_types_group(&self, script: &mut String) {
613        script.push_str("  group('Schema Types', function () {\n");
614
615        let types = [
616            ("string", r#"{ "value": "hello" }"#, "schema:string"),
617            ("integer", r#"{ "value": 42 }"#, "schema:integer"),
618            ("number", r#"{ "value": 3.14 }"#, "schema:number"),
619            ("boolean", r#"{ "value": true }"#, "schema:boolean"),
620            ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
621            ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
622        ];
623
624        for (type_name, body, check_name) in types {
625            script.push_str("    {\n");
626            let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
627            let body_str = format!("'{}'", body);
628            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
629            script.push_str(&format!(
630                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
631                check_name
632            ));
633            script.push_str("    }\n");
634        }
635
636        script.push_str("  });\n\n");
637    }
638
639    fn generate_composition_group(&self, script: &mut String) {
640        script.push_str("  group('Composition', function () {\n");
641
642        let compositions = [
643            ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
644            ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
645            ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
646        ];
647
648        for (kind, body, check_name) in compositions {
649            script.push_str("    {\n");
650            let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
651            let body_str = format!("'{}'", body);
652            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
653            script.push_str(&format!(
654                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
655                check_name
656            ));
657            script.push_str("    }\n");
658        }
659
660        script.push_str("  });\n\n");
661    }
662
663    fn generate_string_formats_group(&self, script: &mut String) {
664        script.push_str("  group('String Formats', function () {\n");
665
666        let formats = [
667            ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
668            ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
669            ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
670            ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
671            ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
672            ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
673            ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
674        ];
675
676        for (fmt, body, check_name) in formats {
677            script.push_str("    {\n");
678            let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
679            let body_str = format!("'{}'", body);
680            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
681            script.push_str(&format!(
682                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
683                check_name
684            ));
685            script.push_str("    }\n");
686        }
687
688        script.push_str("  });\n\n");
689    }
690
691    fn generate_constraints_group(&self, script: &mut String) {
692        script.push_str("  group('Constraints', function () {\n");
693
694        let constraints = [
695            (
696                "required",
697                "JSON.stringify({ required_field: 'present' })",
698                "constraint:required",
699            ),
700            ("optional", "JSON.stringify({})", "constraint:optional"),
701            ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
702            ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
703            ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
704        ];
705
706        for (kind, body, check_name) in constraints {
707            script.push_str("    {\n");
708            let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
709            self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
710            script.push_str(&format!(
711                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
712                check_name
713            ));
714            script.push_str("    }\n");
715        }
716
717        script.push_str("  });\n\n");
718    }
719
720    fn generate_response_codes_group(&self, script: &mut String) {
721        script.push_str("  group('Response Codes', function () {\n");
722
723        let codes = [
724            ("200", "response:200"),
725            ("201", "response:201"),
726            ("204", "response:204"),
727            ("400", "response:400"),
728            ("404", "response:404"),
729        ];
730
731        for (code, check_name) in codes {
732            script.push_str("    {\n");
733            let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
734            self.emit_get(script, &url, None);
735            script.push_str(&format!(
736                "      check(res, {{ '{}': (r) => r.status === {} }});\n",
737                check_name, code
738            ));
739            script.push_str("    }\n");
740        }
741
742        script.push_str("  });\n\n");
743    }
744
745    fn generate_http_methods_group(&self, script: &mut String) {
746        script.push_str("  group('HTTP Methods', function () {\n");
747
748        // GET
749        script.push_str("    {\n");
750        self.emit_get(script, "${BASE_URL}/conformance/methods", None);
751        script.push_str(
752            "      check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
753        );
754        script.push_str("    }\n");
755
756        // POST
757        script.push_str("    {\n");
758        self.emit_post_like(
759            script,
760            "post",
761            "${BASE_URL}/conformance/methods",
762            "JSON.stringify({ action: 'create' })",
763            "JSON_HEADERS",
764        );
765        script.push_str(
766            "      check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
767        );
768        script.push_str("    }\n");
769
770        // PUT
771        script.push_str("    {\n");
772        self.emit_post_like(
773            script,
774            "put",
775            "${BASE_URL}/conformance/methods",
776            "JSON.stringify({ action: 'update' })",
777            "JSON_HEADERS",
778        );
779        script.push_str(
780            "      check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
781        );
782        script.push_str("    }\n");
783
784        // PATCH
785        script.push_str("    {\n");
786        self.emit_post_like(
787            script,
788            "patch",
789            "${BASE_URL}/conformance/methods",
790            "JSON.stringify({ action: 'patch' })",
791            "JSON_HEADERS",
792        );
793        script.push_str(
794            "      check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
795        );
796        script.push_str("    }\n");
797
798        // DELETE
799        script.push_str("    {\n");
800        self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
801        script.push_str(
802            "      check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
803        );
804        script.push_str("    }\n");
805
806        // HEAD
807        script.push_str("    {\n");
808        self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
809        script.push_str(
810            "      check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
811        );
812        script.push_str("    }\n");
813
814        // OPTIONS
815        script.push_str("    {\n");
816        self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
817        script.push_str(
818            "      check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
819        );
820        script.push_str("    }\n");
821
822        script.push_str("  });\n\n");
823    }
824
825    fn generate_content_negotiation_group(&self, script: &mut String) {
826        script.push_str("  group('Content Types', function () {\n");
827
828        script.push_str("    {\n");
829        self.emit_get(
830            script,
831            "${BASE_URL}/conformance/content-types",
832            Some("{ 'Accept': 'application/json' }"),
833        );
834        script.push_str(
835            "      check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
836        );
837        script.push_str("    }\n");
838
839        script.push_str("  });\n\n");
840    }
841
842    fn generate_security_group(&self, script: &mut String) {
843        script.push_str("  group('Security', function () {\n");
844
845        // Bearer token
846        script.push_str("    {\n");
847        self.emit_get(
848            script,
849            "${BASE_URL}/conformance/security/bearer",
850            Some("{ 'Authorization': 'Bearer test-token-123' }"),
851        );
852        script.push_str(
853            "      check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
854        );
855        script.push_str("    }\n");
856
857        // API Key
858        let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
859        script.push_str("    {\n");
860        let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
861        self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
862        script.push_str(
863            "      check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
864        );
865        script.push_str("    }\n");
866
867        // Basic auth
868        let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
869        let encoded = base64_encode(basic_creds);
870        script.push_str("    {\n");
871        let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
872        self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
873        script.push_str(
874            "      check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
875        );
876        script.push_str("    }\n");
877
878        script.push_str("  });\n\n");
879    }
880
881    fn generate_handle_summary(&self, script: &mut String) {
882        // Determine the report output path. When output_dir is set, use an absolute
883        // path so k6 writes the file where the CLI expects to find it regardless of CWD.
884        let report_path = match &self.config.output_dir {
885            Some(dir) => {
886                let abs = std::fs::canonicalize(dir)
887                    .unwrap_or_else(|_| dir.clone())
888                    .join("conformance-report.json");
889                abs.to_string_lossy().to_string()
890            }
891            None => "conformance-report.json".to_string(),
892        };
893
894        script.push_str("export function handleSummary(data) {\n");
895        script.push_str("  // Extract check results for conformance reporting\n");
896        script.push_str("  let checks = {};\n");
897        script.push_str("  if (data.metrics && data.metrics.checks) {\n");
898        script.push_str("    // Overall check pass rate\n");
899        script.push_str("    checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
900        script.push_str("  }\n");
901        script.push_str("  // Collect per-check results from root_group\n");
902        script.push_str("  let checkResults = {};\n");
903        script.push_str("  function walkGroups(group) {\n");
904        script.push_str("    if (group.checks) {\n");
905        script.push_str("      for (let checkObj of group.checks) {\n");
906        script.push_str("        checkResults[checkObj.name] = {\n");
907        script.push_str("          passes: checkObj.passes,\n");
908        script.push_str("          fails: checkObj.fails,\n");
909        script.push_str("        };\n");
910        script.push_str("      }\n");
911        script.push_str("    }\n");
912        script.push_str("    if (group.groups) {\n");
913        script.push_str("      for (let subGroup of group.groups) {\n");
914        script.push_str("        walkGroups(subGroup);\n");
915        script.push_str("      }\n");
916        script.push_str("    }\n");
917        script.push_str("  }\n");
918        script.push_str("  if (data.root_group) {\n");
919        script.push_str("    walkGroups(data.root_group);\n");
920        script.push_str("  }\n");
921        script.push_str("  let result = {\n");
922        script.push_str(&format!(
923            "    '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
924            report_path
925        ));
926        script.push_str("    'summary.json': JSON.stringify(data),\n");
927        script.push_str("    stdout: textSummary(data, { indent: '  ', enableColors: true }),\n");
928        script.push_str("  };\n");
929        script.push_str("  return result;\n");
930        script.push_str("}\n\n");
931        script.push_str("// textSummary fallback\n");
932        script.push_str("function textSummary(data, opts) {\n");
933        script.push_str("  return JSON.stringify(data, null, 2);\n");
934        script.push_str("}\n");
935    }
936}
937
938/// Simple base64 encoding for basic auth
939fn base64_encode(input: &str) -> String {
940    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
941    let bytes = input.as_bytes();
942    let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
943    for chunk in bytes.chunks(3) {
944        let b0 = chunk[0] as u32;
945        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
946        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
947        let triple = (b0 << 16) | (b1 << 8) | b2;
948        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
949        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
950        if chunk.len() > 1 {
951            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
952        } else {
953            result.push('=');
954        }
955        if chunk.len() > 2 {
956            result.push(CHARS[(triple & 0x3F) as usize] as char);
957        } else {
958            result.push('=');
959        }
960    }
961    result
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    #[test]
969    fn test_generate_conformance_script() {
970        let config = ConformanceConfig {
971            target_url: "http://localhost:8080".to_string(),
972            api_key: None,
973            basic_auth: None,
974            skip_tls_verify: false,
975            categories: None,
976            base_path: None,
977            custom_headers: vec![],
978            output_dir: None,
979            all_operations: false,
980            custom_checks_file: None,
981            request_delay_ms: 0,
982            custom_filter: None,
983            export_requests: false,
984            validate_requests: false,
985        };
986        let generator = ConformanceGenerator::new(config);
987        let script = generator.generate().unwrap();
988
989        assert!(script.contains("import http from 'k6/http'"));
990        assert!(script.contains("vus: 1"));
991        assert!(script.contains("iterations: 1"));
992        assert!(script.contains("group('Parameters'"));
993        assert!(script.contains("group('Request Bodies'"));
994        assert!(script.contains("group('Schema Types'"));
995        assert!(script.contains("group('Composition'"));
996        assert!(script.contains("group('String Formats'"));
997        assert!(script.contains("group('Constraints'"));
998        assert!(script.contains("group('Response Codes'"));
999        assert!(script.contains("group('HTTP Methods'"));
1000        assert!(script.contains("group('Content Types'"));
1001        assert!(script.contains("group('Security'"));
1002        assert!(script.contains("handleSummary"));
1003    }
1004
1005    #[test]
1006    fn test_base64_encode() {
1007        assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
1008        assert_eq!(base64_encode("a"), "YQ==");
1009        assert_eq!(base64_encode("ab"), "YWI=");
1010        assert_eq!(base64_encode("abc"), "YWJj");
1011    }
1012
1013    #[test]
1014    fn test_conformance_script_with_custom_auth() {
1015        let config = ConformanceConfig {
1016            target_url: "https://api.example.com".to_string(),
1017            api_key: Some("my-api-key".to_string()),
1018            basic_auth: Some("admin:secret".to_string()),
1019            skip_tls_verify: true,
1020            categories: None,
1021            base_path: None,
1022            custom_headers: vec![],
1023            output_dir: None,
1024            all_operations: false,
1025            custom_checks_file: None,
1026            request_delay_ms: 0,
1027            custom_filter: None,
1028            export_requests: false,
1029            validate_requests: false,
1030        };
1031        let generator = ConformanceGenerator::new(config);
1032        let script = generator.generate().unwrap();
1033
1034        assert!(script.contains("insecureSkipTLSVerify: true"));
1035        assert!(script.contains("my-api-key"));
1036        assert!(script.contains(&base64_encode("admin:secret")));
1037    }
1038
1039    #[test]
1040    fn test_should_include_category_none_includes_all() {
1041        let config = ConformanceConfig {
1042            target_url: "http://localhost:8080".to_string(),
1043            api_key: None,
1044            basic_auth: None,
1045            skip_tls_verify: false,
1046            categories: None,
1047            base_path: None,
1048            custom_headers: vec![],
1049            output_dir: None,
1050            all_operations: false,
1051            custom_checks_file: None,
1052            request_delay_ms: 0,
1053            custom_filter: None,
1054            export_requests: false,
1055            validate_requests: false,
1056        };
1057        assert!(config.should_include_category("Parameters"));
1058        assert!(config.should_include_category("Security"));
1059        assert!(config.should_include_category("Anything"));
1060    }
1061
1062    #[test]
1063    fn test_should_include_category_filtered() {
1064        let config = ConformanceConfig {
1065            target_url: "http://localhost:8080".to_string(),
1066            api_key: None,
1067            basic_auth: None,
1068            skip_tls_verify: false,
1069            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1070            base_path: None,
1071            custom_headers: vec![],
1072            output_dir: None,
1073            all_operations: false,
1074            custom_checks_file: None,
1075            request_delay_ms: 0,
1076            custom_filter: None,
1077            export_requests: false,
1078            validate_requests: false,
1079        };
1080        assert!(config.should_include_category("Parameters"));
1081        assert!(config.should_include_category("Security"));
1082        assert!(config.should_include_category("parameters")); // case-insensitive
1083        assert!(!config.should_include_category("Composition"));
1084        assert!(!config.should_include_category("Schema Types"));
1085    }
1086
1087    #[test]
1088    fn test_generate_with_category_filter() {
1089        let config = ConformanceConfig {
1090            target_url: "http://localhost:8080".to_string(),
1091            api_key: None,
1092            basic_auth: None,
1093            skip_tls_verify: false,
1094            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1095            base_path: None,
1096            custom_headers: vec![],
1097            output_dir: None,
1098            all_operations: false,
1099            custom_checks_file: None,
1100            request_delay_ms: 0,
1101            custom_filter: None,
1102            export_requests: false,
1103            validate_requests: false,
1104        };
1105        let generator = ConformanceGenerator::new(config);
1106        let script = generator.generate().unwrap();
1107
1108        assert!(script.contains("group('Parameters'"));
1109        assert!(script.contains("group('Security'"));
1110        assert!(!script.contains("group('Request Bodies'"));
1111        assert!(!script.contains("group('Schema Types'"));
1112        assert!(!script.contains("group('Composition'"));
1113    }
1114
1115    #[test]
1116    fn test_effective_base_url_no_base_path() {
1117        let config = ConformanceConfig {
1118            target_url: "https://example.com".to_string(),
1119            api_key: None,
1120            basic_auth: None,
1121            skip_tls_verify: false,
1122            categories: None,
1123            base_path: None,
1124            custom_headers: vec![],
1125            output_dir: None,
1126            all_operations: false,
1127            custom_checks_file: None,
1128            request_delay_ms: 0,
1129            custom_filter: None,
1130            export_requests: false,
1131            validate_requests: false,
1132        };
1133        assert_eq!(config.effective_base_url(), "https://example.com");
1134    }
1135
1136    #[test]
1137    fn test_effective_base_url_with_base_path() {
1138        let config = ConformanceConfig {
1139            target_url: "https://example.com".to_string(),
1140            api_key: None,
1141            basic_auth: None,
1142            skip_tls_verify: false,
1143            categories: None,
1144            base_path: Some("/api".to_string()),
1145            custom_headers: vec![],
1146            output_dir: None,
1147            all_operations: false,
1148            custom_checks_file: None,
1149            request_delay_ms: 0,
1150            custom_filter: None,
1151            export_requests: false,
1152            validate_requests: false,
1153        };
1154        assert_eq!(config.effective_base_url(), "https://example.com/api");
1155    }
1156
1157    #[test]
1158    fn test_effective_base_url_trailing_slash_normalization() {
1159        let config = ConformanceConfig {
1160            target_url: "https://example.com/".to_string(),
1161            api_key: None,
1162            basic_auth: None,
1163            skip_tls_verify: false,
1164            categories: None,
1165            base_path: Some("/api".to_string()),
1166            custom_headers: vec![],
1167            output_dir: None,
1168            all_operations: false,
1169            custom_checks_file: None,
1170            request_delay_ms: 0,
1171            custom_filter: None,
1172            export_requests: false,
1173            validate_requests: false,
1174        };
1175        assert_eq!(config.effective_base_url(), "https://example.com/api");
1176    }
1177
1178    #[test]
1179    fn test_effective_base_url_trailing_slash_no_base_path() {
1180        // Regression: --target https://192.168.2.86/ without --base-path
1181        // must not produce double slashes when combined with /path
1182        let config = ConformanceConfig {
1183            target_url: "https://192.168.2.86/".to_string(),
1184            api_key: None,
1185            basic_auth: None,
1186            skip_tls_verify: false,
1187            categories: None,
1188            base_path: None,
1189            custom_headers: vec![],
1190            output_dir: None,
1191            all_operations: false,
1192            custom_checks_file: None,
1193            request_delay_ms: 0,
1194            custom_filter: None,
1195            export_requests: false,
1196            validate_requests: false,
1197        };
1198        assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1199    }
1200
1201    #[test]
1202    fn test_generate_script_with_base_path() {
1203        let config = ConformanceConfig {
1204            target_url: "https://192.168.2.86".to_string(),
1205            api_key: None,
1206            basic_auth: None,
1207            skip_tls_verify: true,
1208            categories: None,
1209            base_path: Some("/api".to_string()),
1210            custom_headers: vec![],
1211            output_dir: None,
1212            all_operations: false,
1213            custom_checks_file: None,
1214            request_delay_ms: 0,
1215            custom_filter: None,
1216            export_requests: false,
1217            validate_requests: false,
1218        };
1219        let generator = ConformanceGenerator::new(config);
1220        let script = generator.generate().unwrap();
1221
1222        assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1223        // Verify URLs include the base path via BASE_URL
1224        assert!(script.contains("${BASE_URL}/conformance/"));
1225    }
1226
1227    #[test]
1228    fn test_generate_with_custom_headers() {
1229        let config = ConformanceConfig {
1230            target_url: "https://192.168.2.86".to_string(),
1231            api_key: None,
1232            basic_auth: None,
1233            skip_tls_verify: true,
1234            categories: Some(vec!["Parameters".to_string()]),
1235            base_path: Some("/api".to_string()),
1236            custom_headers: vec![
1237                ("X-Avi-Tenant".to_string(), "admin".to_string()),
1238                ("X-CSRFToken".to_string(), "real-token".to_string()),
1239            ],
1240            output_dir: None,
1241            all_operations: false,
1242            custom_checks_file: None,
1243            request_delay_ms: 0,
1244            custom_filter: None,
1245            export_requests: false,
1246            validate_requests: false,
1247        };
1248        let generator = ConformanceGenerator::new(config);
1249        let script = generator.generate().unwrap();
1250
1251        // Custom headers should be inlined into requests (no separate const)
1252        assert!(
1253            !script.contains("const CUSTOM_HEADERS"),
1254            "Script should NOT declare a CUSTOM_HEADERS const"
1255        );
1256        assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1257        assert!(script.contains("'X-CSRFToken': 'real-token'"));
1258    }
1259
1260    #[test]
1261    fn test_custom_headers_js_object() {
1262        let config = ConformanceConfig {
1263            target_url: "http://localhost".to_string(),
1264            api_key: None,
1265            basic_auth: None,
1266            skip_tls_verify: false,
1267            categories: None,
1268            base_path: None,
1269            custom_headers: vec![
1270                ("Authorization".to_string(), "Bearer abc123".to_string()),
1271                ("X-Custom".to_string(), "value".to_string()),
1272            ],
1273            output_dir: None,
1274            all_operations: false,
1275            custom_checks_file: None,
1276            request_delay_ms: 0,
1277            custom_filter: None,
1278            export_requests: false,
1279            validate_requests: false,
1280        };
1281        let js = config.custom_headers_js_object();
1282        assert!(js.contains("'Authorization': 'Bearer abc123'"));
1283        assert!(js.contains("'X-Custom': 'value'"));
1284    }
1285}