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