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