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