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
6/// Configuration for conformance test generation
7#[derive(Default)]
8pub struct ConformanceConfig {
9    /// Target base URL
10    pub target_url: String,
11    /// API key for security scheme tests
12    pub api_key: Option<String>,
13    /// Basic auth credentials (user:pass) for security scheme tests
14    pub basic_auth: Option<String>,
15    /// Skip TLS verification
16    pub skip_tls_verify: bool,
17    /// Optional category filter — None means all categories
18    pub categories: Option<Vec<String>>,
19    /// Optional base path prefix for all generated URLs (e.g., "/api")
20    pub base_path: Option<String>,
21    /// Custom headers to inject into every conformance request (e.g., auth headers).
22    /// Each entry is (header_name, header_value). When a custom header matches
23    /// a spec-derived header name, the custom value replaces the placeholder.
24    pub custom_headers: Vec<(String, String)>,
25    /// Output directory for the conformance report (absolute path).
26    /// Used to write `conformance-report.json` to a deterministic location
27    /// so the CLI can find it after k6 execution.
28    pub output_dir: Option<PathBuf>,
29    /// When true, test ALL operations for method/response/body categories
30    /// instead of just one representative per feature check name.
31    pub all_operations: bool,
32}
33
34impl ConformanceConfig {
35    /// Check if a category should be included based on the filter
36    pub fn should_include_category(&self, category: &str) -> bool {
37        match &self.categories {
38            None => true,
39            Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
40        }
41    }
42
43    /// Returns true if custom headers are configured
44    pub fn has_custom_headers(&self) -> bool {
45        !self.custom_headers.is_empty()
46    }
47
48    /// Returns true if custom headers contain a Cookie header.
49    /// When true, k6's automatic cookie jar should be disabled to prevent
50    /// duplicate cookies on subsequent requests.
51    pub fn has_cookie_header(&self) -> bool {
52        self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
53    }
54
55    /// Format custom headers as a JS object literal string
56    pub fn custom_headers_js_object(&self) -> String {
57        let entries: Vec<String> = self
58            .custom_headers
59            .iter()
60            .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
61            .collect();
62        format!("{{ {} }}", entries.join(", "))
63    }
64
65    /// Returns the effective base URL with base_path appended.
66    /// Handles trailing/leading slash normalization to avoid double slashes.
67    pub fn effective_base_url(&self) -> String {
68        match &self.base_path {
69            None => self.target_url.clone(),
70            Some(bp) if bp.is_empty() => self.target_url.clone(),
71            Some(bp) => {
72                let url = self.target_url.trim_end_matches('/');
73                let path = if bp.starts_with('/') {
74                    bp.as_str()
75                } else {
76                    return format!("{}/{}", url, bp);
77                };
78                format!("{}{}", url, path)
79            }
80        }
81    }
82}
83
84/// Generates k6 scripts for OpenAPI 3.0.0 conformance testing
85pub struct ConformanceGenerator {
86    config: ConformanceConfig,
87}
88
89impl ConformanceGenerator {
90    pub fn new(config: ConformanceConfig) -> Self {
91        Self { config }
92    }
93
94    /// Generate the conformance test k6 script
95    pub fn generate(&self) -> Result<String> {
96        let mut script = String::with_capacity(16384);
97
98        // Imports
99        script.push_str("import http from 'k6/http';\n");
100        script.push_str("import { check, group } from 'k6';\n\n");
101
102        // Options: 1 VU, 1 iteration (functional test, not load test)
103        script.push_str("export const options = {\n");
104        script.push_str("  vus: 1,\n");
105        script.push_str("  iterations: 1,\n");
106        if self.config.skip_tls_verify {
107            script.push_str("  insecureSkipTLSVerify: true,\n");
108        }
109        if self.config.has_cookie_header() {
110            script.push_str("  noCookies: true,\n");
111        }
112        script.push_str("  thresholds: {\n");
113        script.push_str("    checks: ['rate>0'],\n");
114        script.push_str("  },\n");
115        script.push_str("};\n\n");
116
117        // Base URL (includes base_path if configured)
118        script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
119
120        // Helper: JSON headers
121        script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
122
123        // Default function
124        script.push_str("export default function () {\n");
125
126        if self.config.should_include_category("Parameters") {
127            self.generate_parameters_group(&mut script);
128        }
129        if self.config.should_include_category("Request Bodies") {
130            self.generate_request_bodies_group(&mut script);
131        }
132        if self.config.should_include_category("Schema Types") {
133            self.generate_schema_types_group(&mut script);
134        }
135        if self.config.should_include_category("Composition") {
136            self.generate_composition_group(&mut script);
137        }
138        if self.config.should_include_category("String Formats") {
139            self.generate_string_formats_group(&mut script);
140        }
141        if self.config.should_include_category("Constraints") {
142            self.generate_constraints_group(&mut script);
143        }
144        if self.config.should_include_category("Response Codes") {
145            self.generate_response_codes_group(&mut script);
146        }
147        if self.config.should_include_category("HTTP Methods") {
148            self.generate_http_methods_group(&mut script);
149        }
150        if self.config.should_include_category("Content Types") {
151            self.generate_content_negotiation_group(&mut script);
152        }
153        if self.config.should_include_category("Security") {
154            self.generate_security_group(&mut script);
155        }
156
157        script.push_str("}\n\n");
158
159        // handleSummary for conformance report output
160        self.generate_handle_summary(&mut script);
161
162        Ok(script)
163    }
164
165    /// Write the generated script to a file
166    pub fn write_script(&self, path: &Path) -> Result<()> {
167        let script = self.generate()?;
168        if let Some(parent) = path.parent() {
169            std::fs::create_dir_all(parent)?;
170        }
171        std::fs::write(path, script)
172            .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
173    }
174
175    /// Returns a JS expression for merging custom headers with provided headers.
176    /// If no custom headers, returns the input as-is.
177    /// If custom headers exist, wraps with Object.assign using inline header object.
178    fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
179        if self.config.has_custom_headers() {
180            format!(
181                "Object.assign({{}}, {}, {})",
182                headers_expr,
183                self.config.custom_headers_js_object()
184            )
185        } else {
186            headers_expr.to_string()
187        }
188    }
189
190    /// Emit a GET request with optional custom headers merged in.
191    fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
192        let has_custom = self.config.has_custom_headers();
193        let custom_obj = self.config.custom_headers_js_object();
194        match (extra_headers, has_custom) {
195            (None, false) => {
196                script.push_str(&format!("      let res = http.get(`{}`);\n", url));
197            }
198            (None, true) => {
199                script.push_str(&format!(
200                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
201                    url, custom_obj
202                ));
203            }
204            (Some(hdrs), false) => {
205                script.push_str(&format!(
206                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
207                    url, hdrs
208                ));
209            }
210            (Some(hdrs), true) => {
211                script.push_str(&format!(
212                    "      let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
213                    url, hdrs, custom_obj
214                ));
215            }
216        }
217    }
218
219    /// Emit a POST/PUT/PATCH request with optional custom headers merged in.
220    fn emit_post_like(
221        &self,
222        script: &mut String,
223        method: &str,
224        url: &str,
225        body: &str,
226        headers_expr: &str,
227    ) {
228        let merged = self.merge_with_custom_headers(headers_expr);
229        script.push_str(&format!(
230            "      let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
231            method, url, body, merged
232        ));
233    }
234
235    /// Emit a DELETE/HEAD/OPTIONS request with optional custom headers.
236    fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
237        if self.config.has_custom_headers() {
238            script.push_str(&format!(
239                "      let res = http.{}(`{}`, {{ headers: {} }});\n",
240                method,
241                url,
242                self.config.custom_headers_js_object()
243            ));
244        } else {
245            script.push_str(&format!("      let res = http.{}(`{}`);\n", method, url));
246        }
247    }
248
249    fn generate_parameters_group(&self, script: &mut String) {
250        script.push_str("  group('Parameters', function () {\n");
251
252        // Path param: string
253        script.push_str("    {\n");
254        self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
255        script.push_str(
256            "      check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
257        );
258        script.push_str("    }\n");
259
260        // Path param: integer
261        script.push_str("    {\n");
262        self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
263        script.push_str(
264            "      check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
265        );
266        script.push_str("    }\n");
267
268        // Query param: string
269        script.push_str("    {\n");
270        self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
271        script.push_str(
272            "      check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
273        );
274        script.push_str("    }\n");
275
276        // Query param: integer
277        script.push_str("    {\n");
278        self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
279        script.push_str(
280            "      check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
281        );
282        script.push_str("    }\n");
283
284        // Query param: array
285        script.push_str("    {\n");
286        self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
287        script.push_str(
288            "      check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
289        );
290        script.push_str("    }\n");
291
292        // Header param
293        script.push_str("    {\n");
294        self.emit_get(
295            script,
296            "${BASE_URL}/conformance/params/header",
297            Some("{ 'X-Custom-Param': 'test-value' }"),
298        );
299        script.push_str(
300            "      check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
301        );
302        script.push_str("    }\n");
303
304        // Cookie param
305        script.push_str("    {\n");
306        script.push_str("      let jar = http.cookieJar();\n");
307        script.push_str("      jar.set(BASE_URL, 'session', 'abc123');\n");
308        self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
309        script.push_str(
310            "      check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
311        );
312        script.push_str("    }\n");
313
314        script.push_str("  });\n\n");
315    }
316
317    fn generate_request_bodies_group(&self, script: &mut String) {
318        script.push_str("  group('Request Bodies', function () {\n");
319
320        // JSON body
321        script.push_str("    {\n");
322        self.emit_post_like(
323            script,
324            "post",
325            "${BASE_URL}/conformance/body/json",
326            "JSON.stringify({ name: 'test', value: 42 })",
327            "JSON_HEADERS",
328        );
329        script.push_str(
330            "      check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
331        );
332        script.push_str("    }\n");
333
334        // Form-urlencoded body
335        script.push_str("    {\n");
336        if self.config.has_custom_headers() {
337            script.push_str(&format!(
338                "      let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
339                self.config.custom_headers_js_object()
340            ));
341        } else {
342            script.push_str(
343                "      let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
344            );
345        }
346        script.push_str(
347            "      check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
348        );
349        script.push_str("    }\n");
350
351        // Multipart body
352        script.push_str("    {\n");
353        script.push_str(
354            "      let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
355        );
356        if self.config.has_custom_headers() {
357            script.push_str(&format!(
358                "      let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
359                self.config.custom_headers_js_object()
360            ));
361        } else {
362            script.push_str(
363                "      let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
364            );
365        }
366        script.push_str(
367            "      check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
368        );
369        script.push_str("    }\n");
370
371        script.push_str("  });\n\n");
372    }
373
374    fn generate_schema_types_group(&self, script: &mut String) {
375        script.push_str("  group('Schema Types', function () {\n");
376
377        let types = [
378            ("string", r#"{ "value": "hello" }"#, "schema:string"),
379            ("integer", r#"{ "value": 42 }"#, "schema:integer"),
380            ("number", r#"{ "value": 3.14 }"#, "schema:number"),
381            ("boolean", r#"{ "value": true }"#, "schema:boolean"),
382            ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
383            ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
384        ];
385
386        for (type_name, body, check_name) in types {
387            script.push_str("    {\n");
388            let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
389            let body_str = format!("'{}'", body);
390            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
391            script.push_str(&format!(
392                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
393                check_name
394            ));
395            script.push_str("    }\n");
396        }
397
398        script.push_str("  });\n\n");
399    }
400
401    fn generate_composition_group(&self, script: &mut String) {
402        script.push_str("  group('Composition', function () {\n");
403
404        let compositions = [
405            ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
406            ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
407            ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
408        ];
409
410        for (kind, body, check_name) in compositions {
411            script.push_str("    {\n");
412            let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
413            let body_str = format!("'{}'", body);
414            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
415            script.push_str(&format!(
416                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
417                check_name
418            ));
419            script.push_str("    }\n");
420        }
421
422        script.push_str("  });\n\n");
423    }
424
425    fn generate_string_formats_group(&self, script: &mut String) {
426        script.push_str("  group('String Formats', function () {\n");
427
428        let formats = [
429            ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
430            ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
431            ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
432            ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
433            ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
434            ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
435            ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
436        ];
437
438        for (fmt, body, check_name) in formats {
439            script.push_str("    {\n");
440            let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
441            let body_str = format!("'{}'", body);
442            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
443            script.push_str(&format!(
444                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
445                check_name
446            ));
447            script.push_str("    }\n");
448        }
449
450        script.push_str("  });\n\n");
451    }
452
453    fn generate_constraints_group(&self, script: &mut String) {
454        script.push_str("  group('Constraints', function () {\n");
455
456        let constraints = [
457            (
458                "required",
459                "JSON.stringify({ required_field: 'present' })",
460                "constraint:required",
461            ),
462            ("optional", "JSON.stringify({})", "constraint:optional"),
463            ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
464            ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
465            ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
466        ];
467
468        for (kind, body, check_name) in constraints {
469            script.push_str("    {\n");
470            let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
471            self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
472            script.push_str(&format!(
473                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
474                check_name
475            ));
476            script.push_str("    }\n");
477        }
478
479        script.push_str("  });\n\n");
480    }
481
482    fn generate_response_codes_group(&self, script: &mut String) {
483        script.push_str("  group('Response Codes', function () {\n");
484
485        let codes = [
486            ("200", "response:200"),
487            ("201", "response:201"),
488            ("204", "response:204"),
489            ("400", "response:400"),
490            ("404", "response:404"),
491        ];
492
493        for (code, check_name) in codes {
494            script.push_str("    {\n");
495            let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
496            self.emit_get(script, &url, None);
497            script.push_str(&format!(
498                "      check(res, {{ '{}': (r) => r.status === {} }});\n",
499                check_name, code
500            ));
501            script.push_str("    }\n");
502        }
503
504        script.push_str("  });\n\n");
505    }
506
507    fn generate_http_methods_group(&self, script: &mut String) {
508        script.push_str("  group('HTTP Methods', function () {\n");
509
510        // GET
511        script.push_str("    {\n");
512        self.emit_get(script, "${BASE_URL}/conformance/methods", None);
513        script.push_str(
514            "      check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
515        );
516        script.push_str("    }\n");
517
518        // POST
519        script.push_str("    {\n");
520        self.emit_post_like(
521            script,
522            "post",
523            "${BASE_URL}/conformance/methods",
524            "JSON.stringify({ action: 'create' })",
525            "JSON_HEADERS",
526        );
527        script.push_str(
528            "      check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
529        );
530        script.push_str("    }\n");
531
532        // PUT
533        script.push_str("    {\n");
534        self.emit_post_like(
535            script,
536            "put",
537            "${BASE_URL}/conformance/methods",
538            "JSON.stringify({ action: 'update' })",
539            "JSON_HEADERS",
540        );
541        script.push_str(
542            "      check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
543        );
544        script.push_str("    }\n");
545
546        // PATCH
547        script.push_str("    {\n");
548        self.emit_post_like(
549            script,
550            "patch",
551            "${BASE_URL}/conformance/methods",
552            "JSON.stringify({ action: 'patch' })",
553            "JSON_HEADERS",
554        );
555        script.push_str(
556            "      check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
557        );
558        script.push_str("    }\n");
559
560        // DELETE
561        script.push_str("    {\n");
562        self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
563        script.push_str(
564            "      check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
565        );
566        script.push_str("    }\n");
567
568        // HEAD
569        script.push_str("    {\n");
570        self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
571        script.push_str(
572            "      check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
573        );
574        script.push_str("    }\n");
575
576        // OPTIONS
577        script.push_str("    {\n");
578        self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
579        script.push_str(
580            "      check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
581        );
582        script.push_str("    }\n");
583
584        script.push_str("  });\n\n");
585    }
586
587    fn generate_content_negotiation_group(&self, script: &mut String) {
588        script.push_str("  group('Content Types', function () {\n");
589
590        script.push_str("    {\n");
591        self.emit_get(
592            script,
593            "${BASE_URL}/conformance/content-types",
594            Some("{ 'Accept': 'application/json' }"),
595        );
596        script.push_str(
597            "      check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
598        );
599        script.push_str("    }\n");
600
601        script.push_str("  });\n\n");
602    }
603
604    fn generate_security_group(&self, script: &mut String) {
605        script.push_str("  group('Security', function () {\n");
606
607        // Bearer token
608        script.push_str("    {\n");
609        self.emit_get(
610            script,
611            "${BASE_URL}/conformance/security/bearer",
612            Some("{ 'Authorization': 'Bearer test-token-123' }"),
613        );
614        script.push_str(
615            "      check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
616        );
617        script.push_str("    }\n");
618
619        // API Key
620        let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
621        script.push_str("    {\n");
622        let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
623        self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
624        script.push_str(
625            "      check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
626        );
627        script.push_str("    }\n");
628
629        // Basic auth
630        let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
631        let encoded = base64_encode(basic_creds);
632        script.push_str("    {\n");
633        let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
634        self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
635        script.push_str(
636            "      check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
637        );
638        script.push_str("    }\n");
639
640        script.push_str("  });\n\n");
641    }
642
643    fn generate_handle_summary(&self, script: &mut String) {
644        // Determine the report output path. When output_dir is set, use an absolute
645        // path so k6 writes the file where the CLI expects to find it regardless of CWD.
646        let report_path = match &self.config.output_dir {
647            Some(dir) => {
648                let abs = std::fs::canonicalize(dir)
649                    .unwrap_or_else(|_| dir.clone())
650                    .join("conformance-report.json");
651                abs.to_string_lossy().to_string()
652            }
653            None => "conformance-report.json".to_string(),
654        };
655
656        script.push_str("export function handleSummary(data) {\n");
657        script.push_str("  // Extract check results for conformance reporting\n");
658        script.push_str("  let checks = {};\n");
659        script.push_str("  if (data.metrics && data.metrics.checks) {\n");
660        script.push_str("    // Overall check pass rate\n");
661        script.push_str("    checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
662        script.push_str("  }\n");
663        script.push_str("  // Collect per-check results from root_group\n");
664        script.push_str("  let checkResults = {};\n");
665        script.push_str("  function walkGroups(group) {\n");
666        script.push_str("    if (group.checks) {\n");
667        script.push_str("      for (let checkObj of group.checks) {\n");
668        script.push_str("        checkResults[checkObj.name] = {\n");
669        script.push_str("          passes: checkObj.passes,\n");
670        script.push_str("          fails: checkObj.fails,\n");
671        script.push_str("        };\n");
672        script.push_str("      }\n");
673        script.push_str("    }\n");
674        script.push_str("    if (group.groups) {\n");
675        script.push_str("      for (let subGroup of group.groups) {\n");
676        script.push_str("        walkGroups(subGroup);\n");
677        script.push_str("      }\n");
678        script.push_str("    }\n");
679        script.push_str("  }\n");
680        script.push_str("  if (data.root_group) {\n");
681        script.push_str("    walkGroups(data.root_group);\n");
682        script.push_str("  }\n");
683        script.push_str("  return {\n");
684        script.push_str(&format!(
685            "    '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
686            report_path
687        ));
688        script.push_str("    stdout: textSummary(data, { indent: '  ', enableColors: true }),\n");
689        script.push_str("  };\n");
690        script.push_str("}\n\n");
691        script.push_str("// textSummary fallback\n");
692        script.push_str("function textSummary(data, opts) {\n");
693        script.push_str("  return JSON.stringify(data, null, 2);\n");
694        script.push_str("}\n");
695    }
696}
697
698/// Simple base64 encoding for basic auth
699fn base64_encode(input: &str) -> String {
700    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
701    let bytes = input.as_bytes();
702    let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
703    for chunk in bytes.chunks(3) {
704        let b0 = chunk[0] as u32;
705        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
706        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
707        let triple = (b0 << 16) | (b1 << 8) | b2;
708        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
709        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
710        if chunk.len() > 1 {
711            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
712        } else {
713            result.push('=');
714        }
715        if chunk.len() > 2 {
716            result.push(CHARS[(triple & 0x3F) as usize] as char);
717        } else {
718            result.push('=');
719        }
720    }
721    result
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn test_generate_conformance_script() {
730        let config = ConformanceConfig {
731            target_url: "http://localhost:8080".to_string(),
732            api_key: None,
733            basic_auth: None,
734            skip_tls_verify: false,
735            categories: None,
736            base_path: None,
737            custom_headers: vec![],
738            output_dir: None,
739            all_operations: false,
740        };
741        let generator = ConformanceGenerator::new(config);
742        let script = generator.generate().unwrap();
743
744        assert!(script.contains("import http from 'k6/http'"));
745        assert!(script.contains("vus: 1"));
746        assert!(script.contains("iterations: 1"));
747        assert!(script.contains("group('Parameters'"));
748        assert!(script.contains("group('Request Bodies'"));
749        assert!(script.contains("group('Schema Types'"));
750        assert!(script.contains("group('Composition'"));
751        assert!(script.contains("group('String Formats'"));
752        assert!(script.contains("group('Constraints'"));
753        assert!(script.contains("group('Response Codes'"));
754        assert!(script.contains("group('HTTP Methods'"));
755        assert!(script.contains("group('Content Types'"));
756        assert!(script.contains("group('Security'"));
757        assert!(script.contains("handleSummary"));
758    }
759
760    #[test]
761    fn test_base64_encode() {
762        assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
763        assert_eq!(base64_encode("a"), "YQ==");
764        assert_eq!(base64_encode("ab"), "YWI=");
765        assert_eq!(base64_encode("abc"), "YWJj");
766    }
767
768    #[test]
769    fn test_conformance_script_with_custom_auth() {
770        let config = ConformanceConfig {
771            target_url: "https://api.example.com".to_string(),
772            api_key: Some("my-api-key".to_string()),
773            basic_auth: Some("admin:secret".to_string()),
774            skip_tls_verify: true,
775            categories: None,
776            base_path: None,
777            custom_headers: vec![],
778            output_dir: None,
779            all_operations: false,
780        };
781        let generator = ConformanceGenerator::new(config);
782        let script = generator.generate().unwrap();
783
784        assert!(script.contains("insecureSkipTLSVerify: true"));
785        assert!(script.contains("my-api-key"));
786        assert!(script.contains(&base64_encode("admin:secret")));
787    }
788
789    #[test]
790    fn test_should_include_category_none_includes_all() {
791        let config = ConformanceConfig {
792            target_url: "http://localhost:8080".to_string(),
793            api_key: None,
794            basic_auth: None,
795            skip_tls_verify: false,
796            categories: None,
797            base_path: None,
798            custom_headers: vec![],
799            output_dir: None,
800            all_operations: false,
801        };
802        assert!(config.should_include_category("Parameters"));
803        assert!(config.should_include_category("Security"));
804        assert!(config.should_include_category("Anything"));
805    }
806
807    #[test]
808    fn test_should_include_category_filtered() {
809        let config = ConformanceConfig {
810            target_url: "http://localhost:8080".to_string(),
811            api_key: None,
812            basic_auth: None,
813            skip_tls_verify: false,
814            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
815            base_path: None,
816            custom_headers: vec![],
817            output_dir: None,
818            all_operations: false,
819        };
820        assert!(config.should_include_category("Parameters"));
821        assert!(config.should_include_category("Security"));
822        assert!(config.should_include_category("parameters")); // case-insensitive
823        assert!(!config.should_include_category("Composition"));
824        assert!(!config.should_include_category("Schema Types"));
825    }
826
827    #[test]
828    fn test_generate_with_category_filter() {
829        let config = ConformanceConfig {
830            target_url: "http://localhost:8080".to_string(),
831            api_key: None,
832            basic_auth: None,
833            skip_tls_verify: false,
834            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
835            base_path: None,
836            custom_headers: vec![],
837            output_dir: None,
838            all_operations: false,
839        };
840        let generator = ConformanceGenerator::new(config);
841        let script = generator.generate().unwrap();
842
843        assert!(script.contains("group('Parameters'"));
844        assert!(script.contains("group('Security'"));
845        assert!(!script.contains("group('Request Bodies'"));
846        assert!(!script.contains("group('Schema Types'"));
847        assert!(!script.contains("group('Composition'"));
848    }
849
850    #[test]
851    fn test_effective_base_url_no_base_path() {
852        let config = ConformanceConfig {
853            target_url: "https://example.com".to_string(),
854            api_key: None,
855            basic_auth: None,
856            skip_tls_verify: false,
857            categories: None,
858            base_path: None,
859            custom_headers: vec![],
860            output_dir: None,
861            all_operations: false,
862        };
863        assert_eq!(config.effective_base_url(), "https://example.com");
864    }
865
866    #[test]
867    fn test_effective_base_url_with_base_path() {
868        let config = ConformanceConfig {
869            target_url: "https://example.com".to_string(),
870            api_key: None,
871            basic_auth: None,
872            skip_tls_verify: false,
873            categories: None,
874            base_path: Some("/api".to_string()),
875            custom_headers: vec![],
876            output_dir: None,
877            all_operations: false,
878        };
879        assert_eq!(config.effective_base_url(), "https://example.com/api");
880    }
881
882    #[test]
883    fn test_effective_base_url_trailing_slash_normalization() {
884        let config = ConformanceConfig {
885            target_url: "https://example.com/".to_string(),
886            api_key: None,
887            basic_auth: None,
888            skip_tls_verify: false,
889            categories: None,
890            base_path: Some("/api".to_string()),
891            custom_headers: vec![],
892            output_dir: None,
893            all_operations: false,
894        };
895        assert_eq!(config.effective_base_url(), "https://example.com/api");
896    }
897
898    #[test]
899    fn test_generate_script_with_base_path() {
900        let config = ConformanceConfig {
901            target_url: "https://192.168.2.86".to_string(),
902            api_key: None,
903            basic_auth: None,
904            skip_tls_verify: true,
905            categories: None,
906            base_path: Some("/api".to_string()),
907            custom_headers: vec![],
908            output_dir: None,
909            all_operations: false,
910        };
911        let generator = ConformanceGenerator::new(config);
912        let script = generator.generate().unwrap();
913
914        assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
915        // Verify URLs include the base path via BASE_URL
916        assert!(script.contains("${BASE_URL}/conformance/"));
917    }
918
919    #[test]
920    fn test_generate_with_custom_headers() {
921        let config = ConformanceConfig {
922            target_url: "https://192.168.2.86".to_string(),
923            api_key: None,
924            basic_auth: None,
925            skip_tls_verify: true,
926            categories: Some(vec!["Parameters".to_string()]),
927            base_path: Some("/api".to_string()),
928            custom_headers: vec![
929                ("X-Avi-Tenant".to_string(), "admin".to_string()),
930                ("X-CSRFToken".to_string(), "real-token".to_string()),
931            ],
932            output_dir: None,
933            all_operations: false,
934        };
935        let generator = ConformanceGenerator::new(config);
936        let script = generator.generate().unwrap();
937
938        // Custom headers should be inlined into requests (no separate const)
939        assert!(
940            !script.contains("const CUSTOM_HEADERS"),
941            "Script should NOT declare a CUSTOM_HEADERS const"
942        );
943        assert!(script.contains("'X-Avi-Tenant': 'admin'"));
944        assert!(script.contains("'X-CSRFToken': 'real-token'"));
945    }
946
947    #[test]
948    fn test_custom_headers_js_object() {
949        let config = ConformanceConfig {
950            target_url: "http://localhost".to_string(),
951            api_key: None,
952            basic_auth: None,
953            skip_tls_verify: false,
954            categories: None,
955            base_path: None,
956            custom_headers: vec![
957                ("Authorization".to_string(), "Bearer abc123".to_string()),
958                ("X-Custom".to_string(), "value".to_string()),
959            ],
960            output_dir: None,
961            all_operations: false,
962        };
963        let js = config.custom_headers_js_object();
964        assert!(js.contains("'Authorization': 'Bearer abc123'"));
965        assert!(js.contains("'X-Custom': 'value'"));
966    }
967}