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