1use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8#[derive(Default, Clone)]
10pub struct ConformanceConfig {
11 pub target_url: String,
13 pub api_key: Option<String>,
15 pub basic_auth: Option<String>,
17 pub skip_tls_verify: bool,
19 pub categories: Option<Vec<String>>,
21 pub base_path: Option<String>,
23 pub custom_headers: Vec<(String, String)>,
27 pub output_dir: Option<PathBuf>,
31 pub all_operations: bool,
34 pub custom_checks_file: Option<PathBuf>,
36 pub request_delay_ms: u64,
39 pub custom_filter: Option<String>,
42 pub export_requests: bool,
45 pub validate_requests: bool,
48}
49
50impl ConformanceConfig {
51 pub fn should_include_category(&self, category: &str) -> bool {
53 match &self.categories {
54 None => true,
55 Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
56 }
57 }
58
59 pub fn has_custom_headers(&self) -> bool {
61 !self.custom_headers.is_empty()
62 }
63
64 pub fn has_cookie_header(&self) -> bool {
68 self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
69 }
70
71 pub fn custom_headers_js_object(&self) -> String {
73 let entries: Vec<String> = self
74 .custom_headers
75 .iter()
76 .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
77 .collect();
78 format!("{{ {} }}", entries.join(", "))
79 }
80
81 pub fn generate_custom_group(
92 &self,
93 ) -> Result<Option<crate::conformance::custom::K6CustomEmit>> {
94 let path = match &self.custom_checks_file {
95 Some(p) => p,
96 None => return Ok(None),
97 };
98 let mut config = CustomConformanceConfig::from_file(path)?;
99 if config.custom_checks.is_empty() {
100 return Ok(None);
101 }
102
103 if let Some(ref pattern) = self.custom_filter {
105 let re = regex::Regex::new(pattern).map_err(|e| {
106 BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
107 })?;
108 let total = config.custom_checks.len();
109 config.custom_checks.retain(|c| re.is_match(&c.name) || re.is_match(&c.path));
110 tracing::info!(
111 "Custom check filter: {}/{} checks matched pattern",
112 config.custom_checks.len(),
113 total
114 );
115 if config.custom_checks.is_empty() {
116 return Ok(None);
117 }
118 }
119
120 Ok(Some(config.emit_k6_with_options(
121 "BASE_URL",
122 &self.custom_headers,
123 self.export_requests,
124 )))
125 }
126
127 pub fn effective_base_url(&self) -> String {
132 let base = match &self.base_path {
133 None => self.target_url.trim_end_matches('/').to_string(),
134 Some(bp) if bp.is_empty() => self.target_url.trim_end_matches('/').to_string(),
135 Some(bp) => {
136 let url = self.target_url.trim_end_matches('/');
137 let path = if bp.starts_with('/') {
138 bp.as_str()
139 } else {
140 return format!("{}/{}", url, bp).trim_end_matches('/').to_string();
141 };
142 format!("{}{}", url, path).trim_end_matches('/').to_string()
143 }
144 };
145 base
146 }
147}
148
149pub struct ConformanceGenerator {
151 config: ConformanceConfig,
152}
153
154impl ConformanceGenerator {
155 pub fn new(config: ConformanceConfig) -> Self {
156 Self { config }
157 }
158
159 pub fn generate(&self) -> Result<String> {
161 let mut script = String::with_capacity(16384);
162
163 script.push_str("import http from 'k6/http';\n");
165 script.push_str("import { check, group } from 'k6';\n");
166 if self.config.request_delay_ms > 0 {
167 script.push_str("import { sleep } from 'k6';\n");
168 }
169 script.push('\n');
170
171 script.push_str(
175 "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
176 );
177
178 script.push_str("export const options = {\n");
180 script.push_str(" vus: 1,\n");
181 script.push_str(" iterations: 1,\n");
182 if self.config.skip_tls_verify {
183 script.push_str(" insecureSkipTLSVerify: true,\n");
184 }
185 script.push_str(" thresholds: {\n");
186 script.push_str(" checks: ['rate>0'],\n");
187 script.push_str(" },\n");
188 script.push_str("};\n\n");
189
190 script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
192
193 if self.config.request_delay_ms > 0 {
195 script.push_str(&format!(
196 "const REQUEST_DELAY = {:.3};\n\n",
197 self.config.request_delay_ms as f64 / 1000.0
198 ));
199 }
200
201 script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
203
204 let custom_emit = self.config.generate_custom_group()?;
210 if let Some(emit) = &custom_emit {
211 if !emit.init_code.is_empty() {
212 script.push_str("// Round 39 (#79) — preloaded upload bytes for custom checks\n");
213 script.push_str(&emit.init_code);
214 script.push('\n');
215 }
216 }
217
218 script.push_str("function __captureFailure(checkName, res, expected) {\n");
220 script.push_str(" let bodyStr = '';\n");
221 script.push_str(" try { if (res.body) { const __n = res.body.length; bodyStr = res.body.substring(0, 65536); if (__n > 65536) bodyStr = bodyStr + ' <truncated at 65536 bytes; full body was ' + __n + ' bytes>'; } else { bodyStr = ''; } } catch(e) { bodyStr = '<unreadable>'; }\n");
222 script.push_str(" let reqHeaders = {};\n");
223 script.push_str(
224 " if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
225 );
226 script.push_str(" let reqBody = '';\n");
227 script.push_str(" if (res.request && res.request.body) { try { const __m = res.request.body.length; reqBody = res.request.body.substring(0, 65536); if (__m > 65536) reqBody = reqBody + ' <truncated at 65536 bytes; full body was ' + __m + ' bytes>'; } catch(e) {} }\n");
228 script.push_str(" console.log('MOCKFORGE_FAILURE:' + JSON.stringify({\n");
229 script.push_str(" check: checkName,\n");
230 script.push_str(" request: {\n");
231 script.push_str(" method: res.request ? res.request.method : 'unknown',\n");
232 script.push_str(" url: res.request ? res.request.url : res.url || 'unknown',\n");
233 script.push_str(" headers: reqHeaders,\n");
234 script.push_str(" body: reqBody,\n");
235 script.push_str(" },\n");
236 script.push_str(" response: {\n");
237 script.push_str(" status: res.status,\n");
238 script.push_str(" headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 20)) : {},\n");
239 script.push_str(" body: bodyStr,\n");
240 script.push_str(" },\n");
241 script.push_str(" expected: expected,\n");
242 script.push_str(" }));\n");
243 script.push_str("}\n\n");
244
245 if self.config.export_requests {
262 script.push_str("function __captureExchange(checkName, res) {\n");
263 script.push_str(" try {\n");
264 script.push_str(" let bodyStr = '';\n");
265 script.push_str(" try { if (res.body) { const __n = res.body.length; bodyStr = res.body.substring(0, 65536); if (__n > 65536) bodyStr = bodyStr + ' <truncated at 65536 bytes; full body was ' + __n + ' bytes>'; } else { bodyStr = ''; } } catch(e) { bodyStr = '<unreadable>'; }\n");
266 script.push_str(" let reqHeaders = {};\n");
267 script.push_str(
268 " if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
269 );
270 script.push_str(" let reqBody = '';\n");
279 script.push_str(" if (res.request && res.request.body) { try { const __m = res.request.body.length; reqBody = res.request.body.substring(0, 65536); if (__m > 65536) reqBody = reqBody + ' <truncated at 65536 bytes; full body was ' + __m + ' bytes>'; } catch(e) {} }\n");
280 script.push_str(
281 " if (!reqBody) {\n\
282 \x20\x20\x20\x20\x20\x20const ct = (reqHeaders['Content-Type'] || reqHeaders['content-type'] || '').toString();\n\
283 \x20\x20\x20\x20\x20\x20if (ct.startsWith('multipart/')) reqBody = '<multipart upload; body bytes not surfaced by k6 res.request.body>';\n\
284 \x20\x20\x20\x20}\n",
285 );
286 script.push_str(" console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
287 script.push_str(" check: checkName,\n");
288 script.push_str(" request: {\n");
289 script.push_str(" method: res.request ? res.request.method : 'unknown',\n");
290 script.push_str(" url: res.request ? res.request.url : res.url || 'unknown',\n");
291 script.push_str(" headers: reqHeaders,\n");
292 script.push_str(" body: reqBody,\n");
293 script.push_str(" },\n");
294 script.push_str(" response: {\n");
295 script.push_str(" status: res.status,\n");
296 script.push_str(" headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
297 script.push_str(" body: bodyStr,\n");
298 script.push_str(" },\n");
299 script.push_str(" }));\n");
300 script.push_str(" } catch (e) {\n");
301 script.push_str(" try {\n");
306 script.push_str(" console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
307 script.push_str(" check: checkName,\n");
308 script.push_str(" request: {\n");
309 script.push_str(
310 " method: (res && res.request) ? res.request.method : 'unknown',\n",
311 );
312 script.push_str(" url: (res && res.request) ? res.request.url : (res && res.url) || 'unknown',\n");
313 script.push_str(" headers: {},\n");
314 script.push_str(" body: '<exchange capture failed: ' + (e && e.message ? e.message : 'unknown error') + '>',\n");
315 script.push_str(" },\n");
316 script.push_str(" response: {\n");
317 script.push_str(" status: (res && res.status) || 0,\n");
318 script.push_str(" headers: {},\n");
319 script.push_str(" body: '',\n");
320 script.push_str(" },\n");
321 script.push_str(" _export_error: (e && e.message) ? e.message : String(e),\n");
322 script.push_str(" }));\n");
323 script.push_str(" } catch (e2) {\n");
324 script.push_str(" console.log('MOCKFORGE_EXCHANGE:{\"check\":\"' + checkName + '\",\"request\":{\"method\":\"unknown\",\"url\":\"unknown\",\"headers\":{},\"body\":\"\"},\"response\":{\"status\":0,\"headers\":{},\"body\":\"\"},\"_export_error\":\"double-fault\"}');\n");
327 script.push_str(" }\n");
328 script.push_str(" }\n");
329 script.push_str("}\n\n");
330 }
331
332 script.push_str("export default function () {\n");
334
335 if self.config.has_cookie_header() {
336 script.push_str(
337 " // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
338 );
339 script.push_str(" http.cookieJar().clear(BASE_URL);\n\n");
340 }
341
342 let delay_between = if self.config.request_delay_ms > 0 {
344 " sleep(REQUEST_DELAY);\n".to_string()
345 } else {
346 String::new()
347 };
348
349 let custom_only = self.config.custom_checks_file.is_some()
358 && !self.config.target_url.is_empty()
359 && self.config.categories.is_none();
362 if !custom_only {
363 if self.config.should_include_category("Parameters") {
364 self.generate_parameters_group(&mut script);
365 script.push_str(&delay_between);
366 }
367 if self.config.should_include_category("Request Bodies") {
368 self.generate_request_bodies_group(&mut script);
369 script.push_str(&delay_between);
370 }
371 if self.config.should_include_category("Schema Types") {
372 self.generate_schema_types_group(&mut script);
373 script.push_str(&delay_between);
374 }
375 if self.config.should_include_category("Composition") {
376 self.generate_composition_group(&mut script);
377 script.push_str(&delay_between);
378 }
379 if self.config.should_include_category("String Formats") {
380 self.generate_string_formats_group(&mut script);
381 script.push_str(&delay_between);
382 }
383 if self.config.should_include_category("Constraints") {
384 self.generate_constraints_group(&mut script);
385 script.push_str(&delay_between);
386 }
387 if self.config.should_include_category("Response Codes") {
388 self.generate_response_codes_group(&mut script);
389 script.push_str(&delay_between);
390 }
391 if self.config.should_include_category("HTTP Methods") {
392 self.generate_http_methods_group(&mut script);
393 script.push_str(&delay_between);
394 }
395 if self.config.should_include_category("Content Types") {
396 self.generate_content_negotiation_group(&mut script);
397 script.push_str(&delay_between);
398 }
399 if self.config.should_include_category("Security") {
400 self.generate_security_group(&mut script);
401 }
402 }
403
404 if let Some(emit) = custom_emit {
409 script.push_str(&emit.group_body);
410 }
411
412 script.push_str("}\n\n");
413
414 self.generate_handle_summary(&mut script);
416
417 Ok(script)
418 }
419
420 pub fn write_script(&self, path: &Path) -> Result<()> {
422 let script = self.generate()?;
423 if let Some(parent) = path.parent() {
424 std::fs::create_dir_all(parent)?;
425 }
426 std::fs::write(path, script)
427 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
428 }
429
430 fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
434 if self.config.has_custom_headers() {
435 format!(
436 "Object.assign({{}}, {}, {})",
437 headers_expr,
438 self.config.custom_headers_js_object()
439 )
440 } else {
441 headers_expr.to_string()
442 }
443 }
444
445 fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
447 let has_custom = self.config.has_custom_headers();
448 let custom_obj = self.config.custom_headers_js_object();
449 match (extra_headers, has_custom) {
450 (None, false) => {
451 script.push_str(&format!(" let res = http.get(`{}`);\n", url));
452 }
453 (None, true) => {
454 script.push_str(&format!(
455 " let res = http.get(`{}`, {{ headers: {} }});\n",
456 url, custom_obj
457 ));
458 }
459 (Some(hdrs), false) => {
460 script.push_str(&format!(
461 " let res = http.get(`{}`, {{ headers: {} }});\n",
462 url, hdrs
463 ));
464 }
465 (Some(hdrs), true) => {
466 script.push_str(&format!(
467 " let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
468 url, hdrs, custom_obj
469 ));
470 }
471 }
472 self.maybe_clear_cookie_jar(script);
473 self.maybe_capture_exchange(script);
474 }
475
476 fn emit_post_like(
478 &self,
479 script: &mut String,
480 method: &str,
481 url: &str,
482 body: &str,
483 headers_expr: &str,
484 ) {
485 let merged = self.merge_with_custom_headers(headers_expr);
486 script.push_str(&format!(
487 " let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
488 method, url, body, merged
489 ));
490 self.maybe_clear_cookie_jar(script);
491 self.maybe_capture_exchange(script);
492 }
493
494 fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
496 if self.config.has_custom_headers() {
497 script.push_str(&format!(
498 " let res = http.{}(`{}`, {{ headers: {} }});\n",
499 method,
500 url,
501 self.config.custom_headers_js_object()
502 ));
503 } else {
504 script.push_str(&format!(" let res = http.{}(`{}`);\n", method, url));
505 }
506 self.maybe_clear_cookie_jar(script);
507 self.maybe_capture_exchange(script);
508 }
509
510 fn maybe_capture_exchange(&self, script: &mut String) {
512 if self.config.export_requests {
513 script.push_str(
514 " if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
515 );
516 }
517 }
518
519 fn maybe_clear_cookie_jar(&self, script: &mut String) {
523 if self.config.has_cookie_header() {
524 script.push_str(" http.cookieJar().clear(BASE_URL);\n");
525 }
526 }
527
528 fn generate_parameters_group(&self, script: &mut String) {
529 script.push_str(" group('Parameters', function () {\n");
530
531 script.push_str(" {\n");
533 self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
534 script.push_str(
535 " check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
536 );
537 script.push_str(" }\n");
538
539 script.push_str(" {\n");
541 self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
542 script.push_str(
543 " check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
544 );
545 script.push_str(" }\n");
546
547 script.push_str(" {\n");
549 self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
550 script.push_str(
551 " check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
552 );
553 script.push_str(" }\n");
554
555 script.push_str(" {\n");
557 self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
558 script.push_str(
559 " check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
560 );
561 script.push_str(" }\n");
562
563 script.push_str(" {\n");
565 self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
566 script.push_str(
567 " check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
568 );
569 script.push_str(" }\n");
570
571 script.push_str(" {\n");
573 self.emit_get(
574 script,
575 "${BASE_URL}/conformance/params/header",
576 Some("{ 'X-Custom-Param': 'test-value' }"),
577 );
578 script.push_str(
579 " check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
580 );
581 script.push_str(" }\n");
582
583 script.push_str(" {\n");
585 script.push_str(" let jar = http.cookieJar();\n");
586 script.push_str(" jar.set(BASE_URL, 'session', 'abc123');\n");
587 self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
588 script.push_str(
589 " check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
590 );
591 script.push_str(" }\n");
592
593 script.push_str(" });\n\n");
594 }
595
596 fn generate_request_bodies_group(&self, script: &mut String) {
597 script.push_str(" group('Request Bodies', function () {\n");
598
599 script.push_str(" {\n");
601 self.emit_post_like(
602 script,
603 "post",
604 "${BASE_URL}/conformance/body/json",
605 "JSON.stringify({ name: 'test', value: 42 })",
606 "JSON_HEADERS",
607 );
608 script.push_str(
609 " check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
610 );
611 script.push_str(" }\n");
612
613 script.push_str(" {\n");
615 if self.config.has_custom_headers() {
616 script.push_str(&format!(
617 " let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
618 self.config.custom_headers_js_object()
619 ));
620 } else {
621 script.push_str(
622 " let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
623 );
624 }
625 self.maybe_clear_cookie_jar(script);
626 script.push_str(
627 " check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
628 );
629 script.push_str(" }\n");
630
631 script.push_str(" {\n");
633 script.push_str(
634 " let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
635 );
636 if self.config.has_custom_headers() {
637 script.push_str(&format!(
638 " let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
639 self.config.custom_headers_js_object()
640 ));
641 } else {
642 script.push_str(
643 " let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
644 );
645 }
646 self.maybe_clear_cookie_jar(script);
647 script.push_str(
648 " check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
649 );
650 script.push_str(" }\n");
651
652 script.push_str(" });\n\n");
653 }
654
655 fn generate_schema_types_group(&self, script: &mut String) {
656 script.push_str(" group('Schema Types', function () {\n");
657
658 let types = [
659 ("string", r#"{ "value": "hello" }"#, "schema:string"),
660 ("integer", r#"{ "value": 42 }"#, "schema:integer"),
661 ("number", r#"{ "value": 3.14 }"#, "schema:number"),
662 ("boolean", r#"{ "value": true }"#, "schema:boolean"),
663 ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
664 ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
665 ];
666
667 for (type_name, body, check_name) in types {
668 script.push_str(" {\n");
669 let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
670 let body_str = format!("'{}'", body);
671 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
672 script.push_str(&format!(
673 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
674 check_name
675 ));
676 script.push_str(" }\n");
677 }
678
679 script.push_str(" });\n\n");
680 }
681
682 fn generate_composition_group(&self, script: &mut String) {
683 script.push_str(" group('Composition', function () {\n");
684
685 let compositions = [
686 ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
687 ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
688 ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
689 ];
690
691 for (kind, body, check_name) in compositions {
692 script.push_str(" {\n");
693 let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
694 let body_str = format!("'{}'", body);
695 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
696 script.push_str(&format!(
697 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
698 check_name
699 ));
700 script.push_str(" }\n");
701 }
702
703 script.push_str(" });\n\n");
704 }
705
706 fn generate_string_formats_group(&self, script: &mut String) {
707 script.push_str(" group('String Formats', function () {\n");
708
709 let formats = [
710 ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
711 ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
712 ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
713 ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
714 ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
715 ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
716 ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
717 ];
718
719 for (fmt, body, check_name) in formats {
720 script.push_str(" {\n");
721 let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
722 let body_str = format!("'{}'", body);
723 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
724 script.push_str(&format!(
725 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
726 check_name
727 ));
728 script.push_str(" }\n");
729 }
730
731 script.push_str(" });\n\n");
732 }
733
734 fn generate_constraints_group(&self, script: &mut String) {
735 script.push_str(" group('Constraints', function () {\n");
736
737 let constraints = [
738 (
739 "required",
740 "JSON.stringify({ required_field: 'present' })",
741 "constraint:required",
742 ),
743 ("optional", "JSON.stringify({})", "constraint:optional"),
744 ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
745 ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
746 ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
747 ];
748
749 for (kind, body, check_name) in constraints {
750 script.push_str(" {\n");
751 let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
752 self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
753 script.push_str(&format!(
754 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
755 check_name
756 ));
757 script.push_str(" }\n");
758 }
759
760 script.push_str(" });\n\n");
761 }
762
763 fn generate_response_codes_group(&self, script: &mut String) {
764 script.push_str(" group('Response Codes', function () {\n");
765
766 let codes = [
767 ("200", "response:200"),
768 ("201", "response:201"),
769 ("204", "response:204"),
770 ("400", "response:400"),
771 ("404", "response:404"),
772 ];
773
774 for (code, check_name) in codes {
775 script.push_str(" {\n");
776 let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
777 self.emit_get(script, &url, None);
778 script.push_str(&format!(
779 " check(res, {{ '{}': (r) => r.status === {} }});\n",
780 check_name, code
781 ));
782 script.push_str(" }\n");
783 }
784
785 script.push_str(" });\n\n");
786 }
787
788 fn generate_http_methods_group(&self, script: &mut String) {
789 script.push_str(" group('HTTP Methods', function () {\n");
790
791 script.push_str(" {\n");
793 self.emit_get(script, "${BASE_URL}/conformance/methods", None);
794 script.push_str(
795 " check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
796 );
797 script.push_str(" }\n");
798
799 script.push_str(" {\n");
801 self.emit_post_like(
802 script,
803 "post",
804 "${BASE_URL}/conformance/methods",
805 "JSON.stringify({ action: 'create' })",
806 "JSON_HEADERS",
807 );
808 script.push_str(
809 " check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
810 );
811 script.push_str(" }\n");
812
813 script.push_str(" {\n");
815 self.emit_post_like(
816 script,
817 "put",
818 "${BASE_URL}/conformance/methods",
819 "JSON.stringify({ action: 'update' })",
820 "JSON_HEADERS",
821 );
822 script.push_str(
823 " check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
824 );
825 script.push_str(" }\n");
826
827 script.push_str(" {\n");
829 self.emit_post_like(
830 script,
831 "patch",
832 "${BASE_URL}/conformance/methods",
833 "JSON.stringify({ action: 'patch' })",
834 "JSON_HEADERS",
835 );
836 script.push_str(
837 " check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
838 );
839 script.push_str(" }\n");
840
841 script.push_str(" {\n");
843 self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
844 script.push_str(
845 " check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
846 );
847 script.push_str(" }\n");
848
849 script.push_str(" {\n");
851 self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
852 script.push_str(
853 " check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
854 );
855 script.push_str(" }\n");
856
857 script.push_str(" {\n");
859 self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
860 script.push_str(
861 " check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
862 );
863 script.push_str(" }\n");
864
865 script.push_str(" });\n\n");
866 }
867
868 fn generate_content_negotiation_group(&self, script: &mut String) {
869 script.push_str(" group('Content Types', function () {\n");
870
871 script.push_str(" {\n");
872 self.emit_get(
873 script,
874 "${BASE_URL}/conformance/content-types",
875 Some("{ 'Accept': 'application/json' }"),
876 );
877 script.push_str(
878 " check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
879 );
880 script.push_str(" }\n");
881
882 script.push_str(" });\n\n");
883 }
884
885 fn generate_security_group(&self, script: &mut String) {
886 script.push_str(" group('Security', function () {\n");
887
888 script.push_str(" {\n");
890 self.emit_get(
891 script,
892 "${BASE_URL}/conformance/security/bearer",
893 Some("{ 'Authorization': 'Bearer test-token-123' }"),
894 );
895 script.push_str(
896 " check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
897 );
898 script.push_str(" }\n");
899
900 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
902 script.push_str(" {\n");
903 let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
904 self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
905 script.push_str(
906 " check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
907 );
908 script.push_str(" }\n");
909
910 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
912 let encoded = base64_encode(basic_creds);
913 script.push_str(" {\n");
914 let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
915 self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
916 script.push_str(
917 " check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
918 );
919 script.push_str(" }\n");
920
921 script.push_str(" });\n\n");
922 }
923
924 fn generate_handle_summary(&self, script: &mut String) {
925 let report_path = match &self.config.output_dir {
928 Some(dir) => {
929 let abs = std::fs::canonicalize(dir)
930 .unwrap_or_else(|_| dir.clone())
931 .join("conformance-report.json");
932 abs.to_string_lossy().to_string()
933 }
934 None => "conformance-report.json".to_string(),
935 };
936
937 script.push_str("export function handleSummary(data) {\n");
938 script.push_str(" // Extract check results for conformance reporting\n");
939 script.push_str(" let checks = {};\n");
940 script.push_str(" if (data.metrics && data.metrics.checks) {\n");
941 script.push_str(" // Overall check pass rate\n");
942 script.push_str(" checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
943 script.push_str(" }\n");
944 script.push_str(" // Collect per-check results from root_group\n");
945 script.push_str(" let checkResults = {};\n");
946 script.push_str(" function walkGroups(group) {\n");
947 script.push_str(" if (group.checks) {\n");
948 script.push_str(" for (let checkObj of group.checks) {\n");
949 script.push_str(" checkResults[checkObj.name] = {\n");
950 script.push_str(" passes: checkObj.passes,\n");
951 script.push_str(" fails: checkObj.fails,\n");
952 script.push_str(" };\n");
953 script.push_str(" }\n");
954 script.push_str(" }\n");
955 script.push_str(" if (group.groups) {\n");
956 script.push_str(" for (let subGroup of group.groups) {\n");
957 script.push_str(" walkGroups(subGroup);\n");
958 script.push_str(" }\n");
959 script.push_str(" }\n");
960 script.push_str(" }\n");
961 script.push_str(" if (data.root_group) {\n");
962 script.push_str(" walkGroups(data.root_group);\n");
963 script.push_str(" }\n");
964 script.push_str(" let result = {\n");
965 script.push_str(&format!(
966 " '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
967 report_path
968 ));
969 script.push_str(" 'summary.json': JSON.stringify(data),\n");
970 script.push_str(" stdout: textSummary(data, { indent: ' ', enableColors: true }),\n");
971 script.push_str(" };\n");
972 script.push_str(" return result;\n");
973 script.push_str("}\n\n");
974 script.push_str("// textSummary fallback\n");
975 script.push_str("function textSummary(data, opts) {\n");
976 script.push_str(" return JSON.stringify(data, null, 2);\n");
977 script.push_str("}\n");
978 }
979}
980
981fn base64_encode(input: &str) -> String {
983 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
984 let bytes = input.as_bytes();
985 let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
986 for chunk in bytes.chunks(3) {
987 let b0 = chunk[0] as u32;
988 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
989 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
990 let triple = (b0 << 16) | (b1 << 8) | b2;
991 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
992 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
993 if chunk.len() > 1 {
994 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
995 } else {
996 result.push('=');
997 }
998 if chunk.len() > 2 {
999 result.push(CHARS[(triple & 0x3F) as usize] as char);
1000 } else {
1001 result.push('=');
1002 }
1003 }
1004 result
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 #[test]
1012 fn test_generate_conformance_script() {
1013 let config = ConformanceConfig {
1014 target_url: "http://localhost:8080".to_string(),
1015 api_key: None,
1016 basic_auth: None,
1017 skip_tls_verify: false,
1018 categories: None,
1019 base_path: None,
1020 custom_headers: vec![],
1021 output_dir: None,
1022 all_operations: false,
1023 custom_checks_file: None,
1024 request_delay_ms: 0,
1025 custom_filter: None,
1026 export_requests: false,
1027 validate_requests: false,
1028 };
1029 let generator = ConformanceGenerator::new(config);
1030 let script = generator.generate().unwrap();
1031
1032 assert!(script.contains("import http from 'k6/http'"));
1033 assert!(script.contains("vus: 1"));
1034 assert!(script.contains("iterations: 1"));
1035 assert!(script.contains("group('Parameters'"));
1036 assert!(script.contains("group('Request Bodies'"));
1037 assert!(script.contains("group('Schema Types'"));
1038 assert!(script.contains("group('Composition'"));
1039 assert!(script.contains("group('String Formats'"));
1040 assert!(script.contains("group('Constraints'"));
1041 assert!(script.contains("group('Response Codes'"));
1042 assert!(script.contains("group('HTTP Methods'"));
1043 assert!(script.contains("group('Content Types'"));
1044 assert!(script.contains("group('Security'"));
1045 assert!(script.contains("handleSummary"));
1046 }
1047
1048 #[test]
1049 fn test_base64_encode() {
1050 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
1051 assert_eq!(base64_encode("a"), "YQ==");
1052 assert_eq!(base64_encode("ab"), "YWI=");
1053 assert_eq!(base64_encode("abc"), "YWJj");
1054 }
1055
1056 #[test]
1057 fn test_conformance_script_with_custom_auth() {
1058 let config = ConformanceConfig {
1059 target_url: "https://api.example.com".to_string(),
1060 api_key: Some("my-api-key".to_string()),
1061 basic_auth: Some("admin:secret".to_string()),
1062 skip_tls_verify: true,
1063 categories: None,
1064 base_path: None,
1065 custom_headers: vec![],
1066 output_dir: None,
1067 all_operations: false,
1068 custom_checks_file: None,
1069 request_delay_ms: 0,
1070 custom_filter: None,
1071 export_requests: false,
1072 validate_requests: false,
1073 };
1074 let generator = ConformanceGenerator::new(config);
1075 let script = generator.generate().unwrap();
1076
1077 assert!(script.contains("insecureSkipTLSVerify: true"));
1078 assert!(script.contains("my-api-key"));
1079 assert!(script.contains(&base64_encode("admin:secret")));
1080 }
1081
1082 #[test]
1083 fn test_should_include_category_none_includes_all() {
1084 let config = ConformanceConfig {
1085 target_url: "http://localhost:8080".to_string(),
1086 api_key: None,
1087 basic_auth: None,
1088 skip_tls_verify: false,
1089 categories: None,
1090 base_path: None,
1091 custom_headers: vec![],
1092 output_dir: None,
1093 all_operations: false,
1094 custom_checks_file: None,
1095 request_delay_ms: 0,
1096 custom_filter: None,
1097 export_requests: false,
1098 validate_requests: false,
1099 };
1100 assert!(config.should_include_category("Parameters"));
1101 assert!(config.should_include_category("Security"));
1102 assert!(config.should_include_category("Anything"));
1103 }
1104
1105 #[test]
1106 fn test_should_include_category_filtered() {
1107 let config = ConformanceConfig {
1108 target_url: "http://localhost:8080".to_string(),
1109 api_key: None,
1110 basic_auth: None,
1111 skip_tls_verify: false,
1112 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1113 base_path: None,
1114 custom_headers: vec![],
1115 output_dir: None,
1116 all_operations: false,
1117 custom_checks_file: None,
1118 request_delay_ms: 0,
1119 custom_filter: None,
1120 export_requests: false,
1121 validate_requests: false,
1122 };
1123 assert!(config.should_include_category("Parameters"));
1124 assert!(config.should_include_category("Security"));
1125 assert!(config.should_include_category("parameters")); assert!(!config.should_include_category("Composition"));
1127 assert!(!config.should_include_category("Schema Types"));
1128 }
1129
1130 #[test]
1131 fn test_generate_with_category_filter() {
1132 let config = ConformanceConfig {
1133 target_url: "http://localhost:8080".to_string(),
1134 api_key: None,
1135 basic_auth: None,
1136 skip_tls_verify: false,
1137 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1138 base_path: None,
1139 custom_headers: vec![],
1140 output_dir: None,
1141 all_operations: false,
1142 custom_checks_file: None,
1143 request_delay_ms: 0,
1144 custom_filter: None,
1145 export_requests: false,
1146 validate_requests: false,
1147 };
1148 let generator = ConformanceGenerator::new(config);
1149 let script = generator.generate().unwrap();
1150
1151 assert!(script.contains("group('Parameters'"));
1152 assert!(script.contains("group('Security'"));
1153 assert!(!script.contains("group('Request Bodies'"));
1154 assert!(!script.contains("group('Schema Types'"));
1155 assert!(!script.contains("group('Composition'"));
1156 }
1157
1158 #[test]
1159 fn test_effective_base_url_no_base_path() {
1160 let config = ConformanceConfig {
1161 target_url: "https://example.com".to_string(),
1162 api_key: None,
1163 basic_auth: None,
1164 skip_tls_verify: false,
1165 categories: None,
1166 base_path: None,
1167 custom_headers: vec![],
1168 output_dir: None,
1169 all_operations: false,
1170 custom_checks_file: None,
1171 request_delay_ms: 0,
1172 custom_filter: None,
1173 export_requests: false,
1174 validate_requests: false,
1175 };
1176 assert_eq!(config.effective_base_url(), "https://example.com");
1177 }
1178
1179 #[test]
1180 fn test_effective_base_url_with_base_path() {
1181 let config = ConformanceConfig {
1182 target_url: "https://example.com".to_string(),
1183 api_key: None,
1184 basic_auth: None,
1185 skip_tls_verify: false,
1186 categories: None,
1187 base_path: Some("/api".to_string()),
1188 custom_headers: vec![],
1189 output_dir: None,
1190 all_operations: false,
1191 custom_checks_file: None,
1192 request_delay_ms: 0,
1193 custom_filter: None,
1194 export_requests: false,
1195 validate_requests: false,
1196 };
1197 assert_eq!(config.effective_base_url(), "https://example.com/api");
1198 }
1199
1200 #[test]
1201 fn test_effective_base_url_trailing_slash_normalization() {
1202 let config = ConformanceConfig {
1203 target_url: "https://example.com/".to_string(),
1204 api_key: None,
1205 basic_auth: None,
1206 skip_tls_verify: false,
1207 categories: None,
1208 base_path: Some("/api".to_string()),
1209 custom_headers: vec![],
1210 output_dir: None,
1211 all_operations: false,
1212 custom_checks_file: None,
1213 request_delay_ms: 0,
1214 custom_filter: None,
1215 export_requests: false,
1216 validate_requests: false,
1217 };
1218 assert_eq!(config.effective_base_url(), "https://example.com/api");
1219 }
1220
1221 #[test]
1222 fn test_effective_base_url_trailing_slash_no_base_path() {
1223 let config = ConformanceConfig {
1226 target_url: "https://192.168.2.86/".to_string(),
1227 api_key: None,
1228 basic_auth: None,
1229 skip_tls_verify: false,
1230 categories: None,
1231 base_path: None,
1232 custom_headers: vec![],
1233 output_dir: None,
1234 all_operations: false,
1235 custom_checks_file: None,
1236 request_delay_ms: 0,
1237 custom_filter: None,
1238 export_requests: false,
1239 validate_requests: false,
1240 };
1241 assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1242 }
1243
1244 #[test]
1245 fn test_generate_script_with_base_path() {
1246 let config = ConformanceConfig {
1247 target_url: "https://192.168.2.86".to_string(),
1248 api_key: None,
1249 basic_auth: None,
1250 skip_tls_verify: true,
1251 categories: None,
1252 base_path: Some("/api".to_string()),
1253 custom_headers: vec![],
1254 output_dir: None,
1255 all_operations: false,
1256 custom_checks_file: None,
1257 request_delay_ms: 0,
1258 custom_filter: None,
1259 export_requests: false,
1260 validate_requests: false,
1261 };
1262 let generator = ConformanceGenerator::new(config);
1263 let script = generator.generate().unwrap();
1264
1265 assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1266 assert!(script.contains("${BASE_URL}/conformance/"));
1268 }
1269
1270 #[test]
1271 fn test_generate_with_custom_headers() {
1272 let config = ConformanceConfig {
1273 target_url: "https://192.168.2.86".to_string(),
1274 api_key: None,
1275 basic_auth: None,
1276 skip_tls_verify: true,
1277 categories: Some(vec!["Parameters".to_string()]),
1278 base_path: Some("/api".to_string()),
1279 custom_headers: vec![
1280 ("X-Avi-Tenant".to_string(), "admin".to_string()),
1281 ("X-CSRFToken".to_string(), "real-token".to_string()),
1282 ],
1283 output_dir: None,
1284 all_operations: false,
1285 custom_checks_file: None,
1286 request_delay_ms: 0,
1287 custom_filter: None,
1288 export_requests: false,
1289 validate_requests: false,
1290 };
1291 let generator = ConformanceGenerator::new(config);
1292 let script = generator.generate().unwrap();
1293
1294 assert!(
1296 !script.contains("const CUSTOM_HEADERS"),
1297 "Script should NOT declare a CUSTOM_HEADERS const"
1298 );
1299 assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1300 assert!(script.contains("'X-CSRFToken': 'real-token'"));
1301 }
1302
1303 #[test]
1304 fn test_custom_headers_js_object() {
1305 let config = ConformanceConfig {
1306 target_url: "http://localhost".to_string(),
1307 api_key: None,
1308 basic_auth: None,
1309 skip_tls_verify: false,
1310 categories: None,
1311 base_path: None,
1312 custom_headers: vec![
1313 ("Authorization".to_string(), "Bearer abc123".to_string()),
1314 ("X-Custom".to_string(), "value".to_string()),
1315 ],
1316 output_dir: None,
1317 all_operations: false,
1318 custom_checks_file: None,
1319 request_delay_ms: 0,
1320 custom_filter: None,
1321 export_requests: false,
1322 validate_requests: false,
1323 };
1324 let js = config.custom_headers_js_object();
1325 assert!(js.contains("'Authorization': 'Bearer abc123'"));
1326 assert!(js.contains("'X-Custom': 'value'"));
1327 }
1328}