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