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