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