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