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