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;
5
6/// Configuration for conformance test generation
7pub struct ConformanceConfig {
8    /// Target base URL
9    pub target_url: String,
10    /// API key for security scheme tests
11    pub api_key: Option<String>,
12    /// Basic auth credentials (user:pass) for security scheme tests
13    pub basic_auth: Option<String>,
14    /// Skip TLS verification
15    pub skip_tls_verify: bool,
16}
17
18/// Generates k6 scripts for OpenAPI 3.0.0 conformance testing
19pub struct ConformanceGenerator {
20    config: ConformanceConfig,
21}
22
23impl ConformanceGenerator {
24    pub fn new(config: ConformanceConfig) -> Self {
25        Self { config }
26    }
27
28    /// Generate the conformance test k6 script
29    pub fn generate(&self) -> Result<String> {
30        let mut script = String::with_capacity(16384);
31
32        // Imports
33        script.push_str("import http from 'k6/http';\n");
34        script.push_str("import { check, group } from 'k6';\n\n");
35
36        // Options: 1 VU, 1 iteration (functional test, not load test)
37        script.push_str("export const options = {\n");
38        script.push_str("  vus: 1,\n");
39        script.push_str("  iterations: 1,\n");
40        if self.config.skip_tls_verify {
41            script.push_str("  insecureSkipTLSVerify: true,\n");
42        }
43        script.push_str("  thresholds: {\n");
44        script.push_str("    checks: ['rate>0'],\n");
45        script.push_str("  },\n");
46        script.push_str("};\n\n");
47
48        // Base URL
49        script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.target_url));
50
51        // Helper: JSON headers
52        script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
53
54        // Default function
55        script.push_str("export default function () {\n");
56
57        // Parameters group
58        self.generate_parameters_group(&mut script);
59        // Request Bodies group
60        self.generate_request_bodies_group(&mut script);
61        // Schema Types group
62        self.generate_schema_types_group(&mut script);
63        // Composition group
64        self.generate_composition_group(&mut script);
65        // String Formats group
66        self.generate_string_formats_group(&mut script);
67        // Constraints group
68        self.generate_constraints_group(&mut script);
69        // Response Codes group
70        self.generate_response_codes_group(&mut script);
71        // HTTP Methods group
72        self.generate_http_methods_group(&mut script);
73        // Content Negotiation group
74        self.generate_content_negotiation_group(&mut script);
75        // Security group
76        self.generate_security_group(&mut script);
77
78        script.push_str("}\n\n");
79
80        // handleSummary for conformance report output
81        self.generate_handle_summary(&mut script);
82
83        Ok(script)
84    }
85
86    /// Write the generated script to a file
87    pub fn write_script(&self, path: &Path) -> Result<()> {
88        let script = self.generate()?;
89        if let Some(parent) = path.parent() {
90            std::fs::create_dir_all(parent)?;
91        }
92        std::fs::write(path, script)
93            .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
94    }
95
96    fn generate_parameters_group(&self, script: &mut String) {
97        script.push_str("  group('Parameters', function () {\n");
98
99        // Path param: string
100        script.push_str("    {\n");
101        script.push_str("      let res = http.get(`${BASE_URL}/conformance/params/hello`);\n");
102        script.push_str(
103            "      check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
104        );
105        script.push_str("    }\n");
106
107        // Path param: integer
108        script.push_str("    {\n");
109        script.push_str("      let res = http.get(`${BASE_URL}/conformance/params/42`);\n");
110        script.push_str(
111            "      check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
112        );
113        script.push_str("    }\n");
114
115        // Query param: string
116        script.push_str("    {\n");
117        script.push_str(
118            "      let res = http.get(`${BASE_URL}/conformance/params/query?name=test`);\n",
119        );
120        script.push_str(
121            "      check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
122        );
123        script.push_str("    }\n");
124
125        // Query param: integer
126        script.push_str("    {\n");
127        script.push_str(
128            "      let res = http.get(`${BASE_URL}/conformance/params/query?count=10`);\n",
129        );
130        script.push_str(
131            "      check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
132        );
133        script.push_str("    }\n");
134
135        // Query param: array
136        script.push_str("    {\n");
137        script.push_str(
138            "      let res = http.get(`${BASE_URL}/conformance/params/query?tags=a&tags=b`);\n",
139        );
140        script.push_str(
141            "      check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
142        );
143        script.push_str("    }\n");
144
145        // Header param
146        script.push_str("    {\n");
147        script.push_str(
148            "      let res = http.get(`${BASE_URL}/conformance/params/header`, { headers: { 'X-Custom-Param': 'test-value' } });\n",
149        );
150        script.push_str(
151            "      check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
152        );
153        script.push_str("    }\n");
154
155        // Cookie param
156        script.push_str("    {\n");
157        script.push_str("      let jar = http.cookieJar();\n");
158        script.push_str("      jar.set(BASE_URL, 'session', 'abc123');\n");
159        script.push_str("      let res = http.get(`${BASE_URL}/conformance/params/cookie`);\n");
160        script.push_str(
161            "      check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
162        );
163        script.push_str("    }\n");
164
165        script.push_str("  });\n\n");
166    }
167
168    fn generate_request_bodies_group(&self, script: &mut String) {
169        script.push_str("  group('Request Bodies', function () {\n");
170
171        // JSON body
172        script.push_str("    {\n");
173        script.push_str(
174            "      let res = http.post(`${BASE_URL}/conformance/body/json`, JSON.stringify({ name: 'test', value: 42 }), { headers: JSON_HEADERS });\n",
175        );
176        script.push_str(
177            "      check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
178        );
179        script.push_str("    }\n");
180
181        // Form-urlencoded body
182        script.push_str("    {\n");
183        script.push_str(
184            "      let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
185        );
186        script.push_str(
187            "      check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
188        );
189        script.push_str("    }\n");
190
191        // Multipart body
192        script.push_str("    {\n");
193        script.push_str(
194            "      let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
195        );
196        script.push_str(
197            "      let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
198        );
199        script.push_str(
200            "      check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
201        );
202        script.push_str("    }\n");
203
204        script.push_str("  });\n\n");
205    }
206
207    fn generate_schema_types_group(&self, script: &mut String) {
208        script.push_str("  group('Schema Types', function () {\n");
209
210        let types = [
211            ("string", r#"{ "value": "hello" }"#, "schema:string"),
212            ("integer", r#"{ "value": 42 }"#, "schema:integer"),
213            ("number", r#"{ "value": 3.14 }"#, "schema:number"),
214            ("boolean", r#"{ "value": true }"#, "schema:boolean"),
215            ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
216            ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
217        ];
218
219        for (type_name, body, check_name) in types {
220            script.push_str("    {\n");
221            script.push_str(&format!(
222                "      let res = http.post(`${{BASE_URL}}/conformance/schema/{}`, '{}', {{ headers: JSON_HEADERS }});\n",
223                type_name, body
224            ));
225            script.push_str(&format!(
226                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
227                check_name
228            ));
229            script.push_str("    }\n");
230        }
231
232        script.push_str("  });\n\n");
233    }
234
235    fn generate_composition_group(&self, script: &mut String) {
236        script.push_str("  group('Composition', function () {\n");
237
238        let compositions = [
239            ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
240            ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
241            ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
242        ];
243
244        for (kind, body, check_name) in compositions {
245            script.push_str("    {\n");
246            script.push_str(&format!(
247                "      let res = http.post(`${{BASE_URL}}/conformance/composition/{}`, '{}', {{ headers: JSON_HEADERS }});\n",
248                kind, body
249            ));
250            script.push_str(&format!(
251                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
252                check_name
253            ));
254            script.push_str("    }\n");
255        }
256
257        script.push_str("  });\n\n");
258    }
259
260    fn generate_string_formats_group(&self, script: &mut String) {
261        script.push_str("  group('String Formats', function () {\n");
262
263        let formats = [
264            ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
265            ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
266            ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
267            ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
268            ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
269            ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
270            ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
271        ];
272
273        for (fmt, body, check_name) in formats {
274            script.push_str("    {\n");
275            script.push_str(&format!(
276                "      let res = http.post(`${{BASE_URL}}/conformance/formats/{}`, '{}', {{ headers: JSON_HEADERS }});\n",
277                fmt, body
278            ));
279            script.push_str(&format!(
280                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
281                check_name
282            ));
283            script.push_str("    }\n");
284        }
285
286        script.push_str("  });\n\n");
287    }
288
289    fn generate_constraints_group(&self, script: &mut String) {
290        script.push_str("  group('Constraints', function () {\n");
291
292        // Required field
293        script.push_str("    {\n");
294        script.push_str(
295            "      let res = http.post(`${BASE_URL}/conformance/constraints/required`, JSON.stringify({ required_field: 'present' }), { headers: JSON_HEADERS });\n",
296        );
297        script.push_str(
298            "      check(res, { 'constraint:required': (r) => r.status >= 200 && r.status < 500 });\n",
299        );
300        script.push_str("    }\n");
301
302        // Optional field
303        script.push_str("    {\n");
304        script.push_str(
305            "      let res = http.post(`${BASE_URL}/conformance/constraints/optional`, JSON.stringify({}), { headers: JSON_HEADERS });\n",
306        );
307        script.push_str(
308            "      check(res, { 'constraint:optional': (r) => r.status >= 200 && r.status < 500 });\n",
309        );
310        script.push_str("    }\n");
311
312        // Min/max
313        script.push_str("    {\n");
314        script.push_str(
315            "      let res = http.post(`${BASE_URL}/conformance/constraints/minmax`, JSON.stringify({ value: 50 }), { headers: JSON_HEADERS });\n",
316        );
317        script.push_str(
318            "      check(res, { 'constraint:minmax': (r) => r.status >= 200 && r.status < 500 });\n",
319        );
320        script.push_str("    }\n");
321
322        // Pattern
323        script.push_str("    {\n");
324        script.push_str(
325            "      let res = http.post(`${BASE_URL}/conformance/constraints/pattern`, JSON.stringify({ value: 'ABC-123' }), { headers: JSON_HEADERS });\n",
326        );
327        script.push_str(
328            "      check(res, { 'constraint:pattern': (r) => r.status >= 200 && r.status < 500 });\n",
329        );
330        script.push_str("    }\n");
331
332        // Enum
333        script.push_str("    {\n");
334        script.push_str(
335            "      let res = http.post(`${BASE_URL}/conformance/constraints/enum`, JSON.stringify({ status: 'active' }), { headers: JSON_HEADERS });\n",
336        );
337        script.push_str(
338            "      check(res, { 'constraint:enum': (r) => r.status >= 200 && r.status < 500 });\n",
339        );
340        script.push_str("    }\n");
341
342        script.push_str("  });\n\n");
343    }
344
345    fn generate_response_codes_group(&self, script: &mut String) {
346        script.push_str("  group('Response Codes', function () {\n");
347
348        let codes = [
349            ("200", "response:200"),
350            ("201", "response:201"),
351            ("204", "response:204"),
352            ("400", "response:400"),
353            ("404", "response:404"),
354        ];
355
356        for (code, check_name) in codes {
357            script.push_str("    {\n");
358            script.push_str(&format!(
359                "      let res = http.get(`${{BASE_URL}}/conformance/responses/{}`);\n",
360                code
361            ));
362            script.push_str(&format!(
363                "      check(res, {{ '{}': (r) => r.status === {} }});\n",
364                check_name, code
365            ));
366            script.push_str("    }\n");
367        }
368
369        script.push_str("  });\n\n");
370    }
371
372    fn generate_http_methods_group(&self, script: &mut String) {
373        script.push_str("  group('HTTP Methods', function () {\n");
374
375        // GET
376        script.push_str("    {\n");
377        script.push_str("      let res = http.get(`${BASE_URL}/conformance/methods`);\n");
378        script.push_str(
379            "      check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
380        );
381        script.push_str("    }\n");
382
383        // POST
384        script.push_str("    {\n");
385        script.push_str(
386            "      let res = http.post(`${BASE_URL}/conformance/methods`, JSON.stringify({ action: 'create' }), { headers: JSON_HEADERS });\n",
387        );
388        script.push_str(
389            "      check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
390        );
391        script.push_str("    }\n");
392
393        // PUT
394        script.push_str("    {\n");
395        script.push_str(
396            "      let res = http.put(`${BASE_URL}/conformance/methods`, JSON.stringify({ action: 'update' }), { headers: JSON_HEADERS });\n",
397        );
398        script.push_str(
399            "      check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
400        );
401        script.push_str("    }\n");
402
403        // PATCH
404        script.push_str("    {\n");
405        script.push_str(
406            "      let res = http.patch(`${BASE_URL}/conformance/methods`, JSON.stringify({ action: 'patch' }), { headers: JSON_HEADERS });\n",
407        );
408        script.push_str(
409            "      check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
410        );
411        script.push_str("    }\n");
412
413        // DELETE
414        script.push_str("    {\n");
415        script.push_str("      let res = http.del(`${BASE_URL}/conformance/methods`);\n");
416        script.push_str(
417            "      check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
418        );
419        script.push_str("    }\n");
420
421        // HEAD
422        script.push_str("    {\n");
423        script.push_str("      let res = http.head(`${BASE_URL}/conformance/methods`);\n");
424        script.push_str(
425            "      check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
426        );
427        script.push_str("    }\n");
428
429        // OPTIONS
430        script.push_str("    {\n");
431        script.push_str("      let res = http.options(`${BASE_URL}/conformance/methods`);\n");
432        script.push_str(
433            "      check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
434        );
435        script.push_str("    }\n");
436
437        script.push_str("  });\n\n");
438    }
439
440    fn generate_content_negotiation_group(&self, script: &mut String) {
441        script.push_str("  group('Content Types', function () {\n");
442
443        script.push_str("    {\n");
444        script.push_str(
445            "      let res = http.get(`${BASE_URL}/conformance/content-types`, { headers: { 'Accept': 'application/json' } });\n",
446        );
447        script.push_str(
448            "      check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
449        );
450        script.push_str("    }\n");
451
452        script.push_str("  });\n\n");
453    }
454
455    fn generate_security_group(&self, script: &mut String) {
456        script.push_str("  group('Security', function () {\n");
457
458        // Bearer token
459        script.push_str("    {\n");
460        script.push_str(
461            "      let res = http.get(`${BASE_URL}/conformance/security/bearer`, { headers: { 'Authorization': 'Bearer test-token-123' } });\n",
462        );
463        script.push_str(
464            "      check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
465        );
466        script.push_str("    }\n");
467
468        // API Key
469        let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
470        script.push_str("    {\n");
471        script.push_str(&format!(
472            "      let res = http.get(`${{BASE_URL}}/conformance/security/apikey`, {{ headers: {{ 'X-API-Key': '{}' }} }});\n",
473            api_key
474        ));
475        script.push_str(
476            "      check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
477        );
478        script.push_str("    }\n");
479
480        // Basic auth
481        let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
482        let encoded = base64_encode(basic_creds);
483        script.push_str("    {\n");
484        script.push_str(&format!(
485            "      let res = http.get(`${{BASE_URL}}/conformance/security/basic`, {{ headers: {{ 'Authorization': 'Basic {}' }} }});\n",
486            encoded
487        ));
488        script.push_str(
489            "      check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
490        );
491        script.push_str("    }\n");
492
493        script.push_str("  });\n\n");
494    }
495
496    fn generate_handle_summary(&self, script: &mut String) {
497        script.push_str("export function handleSummary(data) {\n");
498        script.push_str("  // Extract check results for conformance reporting\n");
499        script.push_str("  let checks = {};\n");
500        script.push_str("  if (data.metrics && data.metrics.checks) {\n");
501        script.push_str("    // Overall check pass rate\n");
502        script.push_str("    checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
503        script.push_str("  }\n");
504        script.push_str("  // Collect per-check results from root_group\n");
505        script.push_str("  let checkResults = {};\n");
506        script.push_str("  function walkGroups(group) {\n");
507        script.push_str("    if (group.checks) {\n");
508        script.push_str("      for (let checkObj of group.checks) {\n");
509        script.push_str("        checkResults[checkObj.name] = {\n");
510        script.push_str("          passes: checkObj.passes,\n");
511        script.push_str("          fails: checkObj.fails,\n");
512        script.push_str("        };\n");
513        script.push_str("      }\n");
514        script.push_str("    }\n");
515        script.push_str("    if (group.groups) {\n");
516        script.push_str("      for (let subGroup of group.groups) {\n");
517        script.push_str("        walkGroups(subGroup);\n");
518        script.push_str("      }\n");
519        script.push_str("    }\n");
520        script.push_str("  }\n");
521        script.push_str("  if (data.root_group) {\n");
522        script.push_str("    walkGroups(data.root_group);\n");
523        script.push_str("  }\n");
524        script.push_str("  return {\n");
525        script.push_str("    'conformance-report.json': JSON.stringify({ checks: checkResults, overall: checks }, null, 2),\n");
526        script.push_str("    stdout: textSummary(data, { indent: '  ', enableColors: true }),\n");
527        script.push_str("  };\n");
528        script.push_str("}\n\n");
529        script.push_str("// textSummary fallback\n");
530        script.push_str("function textSummary(data, opts) {\n");
531        script.push_str("  return JSON.stringify(data, null, 2);\n");
532        script.push_str("}\n");
533    }
534}
535
536/// Simple base64 encoding for basic auth
537fn base64_encode(input: &str) -> String {
538    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
539    let bytes = input.as_bytes();
540    let mut result = String::with_capacity((bytes.len() + 2) / 3 * 4);
541    for chunk in bytes.chunks(3) {
542        let b0 = chunk[0] as u32;
543        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
544        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
545        let triple = (b0 << 16) | (b1 << 8) | b2;
546        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
547        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
548        if chunk.len() > 1 {
549            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
550        } else {
551            result.push('=');
552        }
553        if chunk.len() > 2 {
554            result.push(CHARS[(triple & 0x3F) as usize] as char);
555        } else {
556            result.push('=');
557        }
558    }
559    result
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn test_generate_conformance_script() {
568        let config = ConformanceConfig {
569            target_url: "http://localhost:8080".to_string(),
570            api_key: None,
571            basic_auth: None,
572            skip_tls_verify: false,
573        };
574        let generator = ConformanceGenerator::new(config);
575        let script = generator.generate().unwrap();
576
577        assert!(script.contains("import http from 'k6/http'"));
578        assert!(script.contains("vus: 1"));
579        assert!(script.contains("iterations: 1"));
580        assert!(script.contains("group('Parameters'"));
581        assert!(script.contains("group('Request Bodies'"));
582        assert!(script.contains("group('Schema Types'"));
583        assert!(script.contains("group('Composition'"));
584        assert!(script.contains("group('String Formats'"));
585        assert!(script.contains("group('Constraints'"));
586        assert!(script.contains("group('Response Codes'"));
587        assert!(script.contains("group('HTTP Methods'"));
588        assert!(script.contains("group('Content Types'"));
589        assert!(script.contains("group('Security'"));
590        assert!(script.contains("handleSummary"));
591    }
592
593    #[test]
594    fn test_base64_encode() {
595        assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
596        assert_eq!(base64_encode("a"), "YQ==");
597        assert_eq!(base64_encode("ab"), "YWI=");
598        assert_eq!(base64_encode("abc"), "YWJj");
599    }
600
601    #[test]
602    fn test_conformance_script_with_custom_auth() {
603        let config = ConformanceConfig {
604            target_url: "https://api.example.com".to_string(),
605            api_key: Some("my-api-key".to_string()),
606            basic_auth: Some("admin:secret".to_string()),
607            skip_tls_verify: true,
608        };
609        let generator = ConformanceGenerator::new(config);
610        let script = generator.generate().unwrap();
611
612        assert!(script.contains("insecureSkipTLSVerify: true"));
613        assert!(script.contains("my-api-key"));
614        assert!(script.contains(&base64_encode("admin:secret")));
615    }
616}