Skip to main content

mockforge_bench/conformance/
generator.rs

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