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 { bodyStr = res.body ? res.body.substring(0, 2000) : ''; } 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 { reqBody = res.request.body.substring(0, 2000); } 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 {
249 script.push_str("function __captureExchange(checkName, res) {\n");
250 script.push_str(" let bodyStr = '';\n");
251 script.push_str(" try { bodyStr = res.body ? res.body.substring(0, 2000) : ''; } catch(e) { bodyStr = '<unreadable>'; }\n");
252 script.push_str(" let reqHeaders = {};\n");
253 script.push_str(
254 " if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
255 );
256 script.push_str(" let reqBody = '';\n");
265 script.push_str(" if (res.request && res.request.body) { try { reqBody = res.request.body.substring(0, 2000); } catch(e) {} }\n");
266 script.push_str(
267 " if (!reqBody) {\n\
268 \x20\x20\x20\x20const ct = (reqHeaders['Content-Type'] || reqHeaders['content-type'] || '').toString();\n\
269 \x20\x20\x20\x20if (ct.startsWith('multipart/')) reqBody = '<multipart upload; body bytes not surfaced by k6 res.request.body>';\n\
270 \x20\x20}\n",
271 );
272 script.push_str(" console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
273 script.push_str(" check: checkName,\n");
274 script.push_str(" request: {\n");
275 script.push_str(" method: res.request ? res.request.method : 'unknown',\n");
276 script.push_str(" url: res.request ? res.request.url : res.url || 'unknown',\n");
277 script.push_str(" headers: reqHeaders,\n");
278 script.push_str(" body: reqBody,\n");
279 script.push_str(" },\n");
280 script.push_str(" response: {\n");
281 script.push_str(" status: res.status,\n");
282 script.push_str(" headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
283 script.push_str(" body: bodyStr,\n");
284 script.push_str(" },\n");
285 script.push_str(" }));\n");
286 script.push_str("}\n\n");
287 }
288
289 script.push_str("export default function () {\n");
291
292 if self.config.has_cookie_header() {
293 script.push_str(
294 " // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
295 );
296 script.push_str(" http.cookieJar().clear(BASE_URL);\n\n");
297 }
298
299 let delay_between = if self.config.request_delay_ms > 0 {
301 " sleep(REQUEST_DELAY);\n".to_string()
302 } else {
303 String::new()
304 };
305
306 let custom_only = self.config.custom_checks_file.is_some()
315 && !self.config.target_url.is_empty()
316 && self.config.categories.is_none();
319 if !custom_only {
320 if self.config.should_include_category("Parameters") {
321 self.generate_parameters_group(&mut script);
322 script.push_str(&delay_between);
323 }
324 if self.config.should_include_category("Request Bodies") {
325 self.generate_request_bodies_group(&mut script);
326 script.push_str(&delay_between);
327 }
328 if self.config.should_include_category("Schema Types") {
329 self.generate_schema_types_group(&mut script);
330 script.push_str(&delay_between);
331 }
332 if self.config.should_include_category("Composition") {
333 self.generate_composition_group(&mut script);
334 script.push_str(&delay_between);
335 }
336 if self.config.should_include_category("String Formats") {
337 self.generate_string_formats_group(&mut script);
338 script.push_str(&delay_between);
339 }
340 if self.config.should_include_category("Constraints") {
341 self.generate_constraints_group(&mut script);
342 script.push_str(&delay_between);
343 }
344 if self.config.should_include_category("Response Codes") {
345 self.generate_response_codes_group(&mut script);
346 script.push_str(&delay_between);
347 }
348 if self.config.should_include_category("HTTP Methods") {
349 self.generate_http_methods_group(&mut script);
350 script.push_str(&delay_between);
351 }
352 if self.config.should_include_category("Content Types") {
353 self.generate_content_negotiation_group(&mut script);
354 script.push_str(&delay_between);
355 }
356 if self.config.should_include_category("Security") {
357 self.generate_security_group(&mut script);
358 }
359 }
360
361 if let Some(emit) = custom_emit {
366 script.push_str(&emit.group_body);
367 }
368
369 script.push_str("}\n\n");
370
371 self.generate_handle_summary(&mut script);
373
374 Ok(script)
375 }
376
377 pub fn write_script(&self, path: &Path) -> Result<()> {
379 let script = self.generate()?;
380 if let Some(parent) = path.parent() {
381 std::fs::create_dir_all(parent)?;
382 }
383 std::fs::write(path, script)
384 .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
385 }
386
387 fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
391 if self.config.has_custom_headers() {
392 format!(
393 "Object.assign({{}}, {}, {})",
394 headers_expr,
395 self.config.custom_headers_js_object()
396 )
397 } else {
398 headers_expr.to_string()
399 }
400 }
401
402 fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
404 let has_custom = self.config.has_custom_headers();
405 let custom_obj = self.config.custom_headers_js_object();
406 match (extra_headers, has_custom) {
407 (None, false) => {
408 script.push_str(&format!(" let res = http.get(`{}`);\n", url));
409 }
410 (None, true) => {
411 script.push_str(&format!(
412 " let res = http.get(`{}`, {{ headers: {} }});\n",
413 url, custom_obj
414 ));
415 }
416 (Some(hdrs), false) => {
417 script.push_str(&format!(
418 " let res = http.get(`{}`, {{ headers: {} }});\n",
419 url, hdrs
420 ));
421 }
422 (Some(hdrs), true) => {
423 script.push_str(&format!(
424 " let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
425 url, hdrs, custom_obj
426 ));
427 }
428 }
429 self.maybe_clear_cookie_jar(script);
430 self.maybe_capture_exchange(script);
431 }
432
433 fn emit_post_like(
435 &self,
436 script: &mut String,
437 method: &str,
438 url: &str,
439 body: &str,
440 headers_expr: &str,
441 ) {
442 let merged = self.merge_with_custom_headers(headers_expr);
443 script.push_str(&format!(
444 " let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
445 method, url, body, merged
446 ));
447 self.maybe_clear_cookie_jar(script);
448 self.maybe_capture_exchange(script);
449 }
450
451 fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
453 if self.config.has_custom_headers() {
454 script.push_str(&format!(
455 " let res = http.{}(`{}`, {{ headers: {} }});\n",
456 method,
457 url,
458 self.config.custom_headers_js_object()
459 ));
460 } else {
461 script.push_str(&format!(" let res = http.{}(`{}`);\n", method, url));
462 }
463 self.maybe_clear_cookie_jar(script);
464 self.maybe_capture_exchange(script);
465 }
466
467 fn maybe_capture_exchange(&self, script: &mut String) {
469 if self.config.export_requests {
470 script.push_str(
471 " if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
472 );
473 }
474 }
475
476 fn maybe_clear_cookie_jar(&self, script: &mut String) {
480 if self.config.has_cookie_header() {
481 script.push_str(" http.cookieJar().clear(BASE_URL);\n");
482 }
483 }
484
485 fn generate_parameters_group(&self, script: &mut String) {
486 script.push_str(" group('Parameters', function () {\n");
487
488 script.push_str(" {\n");
490 self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
491 script.push_str(
492 " check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
493 );
494 script.push_str(" }\n");
495
496 script.push_str(" {\n");
498 self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
499 script.push_str(
500 " check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
501 );
502 script.push_str(" }\n");
503
504 script.push_str(" {\n");
506 self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
507 script.push_str(
508 " check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
509 );
510 script.push_str(" }\n");
511
512 script.push_str(" {\n");
514 self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
515 script.push_str(
516 " check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
517 );
518 script.push_str(" }\n");
519
520 script.push_str(" {\n");
522 self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
523 script.push_str(
524 " check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
525 );
526 script.push_str(" }\n");
527
528 script.push_str(" {\n");
530 self.emit_get(
531 script,
532 "${BASE_URL}/conformance/params/header",
533 Some("{ 'X-Custom-Param': 'test-value' }"),
534 );
535 script.push_str(
536 " check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
537 );
538 script.push_str(" }\n");
539
540 script.push_str(" {\n");
542 script.push_str(" let jar = http.cookieJar();\n");
543 script.push_str(" jar.set(BASE_URL, 'session', 'abc123');\n");
544 self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
545 script.push_str(
546 " check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
547 );
548 script.push_str(" }\n");
549
550 script.push_str(" });\n\n");
551 }
552
553 fn generate_request_bodies_group(&self, script: &mut String) {
554 script.push_str(" group('Request Bodies', function () {\n");
555
556 script.push_str(" {\n");
558 self.emit_post_like(
559 script,
560 "post",
561 "${BASE_URL}/conformance/body/json",
562 "JSON.stringify({ name: 'test', value: 42 })",
563 "JSON_HEADERS",
564 );
565 script.push_str(
566 " check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
567 );
568 script.push_str(" }\n");
569
570 script.push_str(" {\n");
572 if self.config.has_custom_headers() {
573 script.push_str(&format!(
574 " let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
575 self.config.custom_headers_js_object()
576 ));
577 } else {
578 script.push_str(
579 " let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
580 );
581 }
582 self.maybe_clear_cookie_jar(script);
583 script.push_str(
584 " check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
585 );
586 script.push_str(" }\n");
587
588 script.push_str(" {\n");
590 script.push_str(
591 " let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
592 );
593 if self.config.has_custom_headers() {
594 script.push_str(&format!(
595 " let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
596 self.config.custom_headers_js_object()
597 ));
598 } else {
599 script.push_str(
600 " let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
601 );
602 }
603 self.maybe_clear_cookie_jar(script);
604 script.push_str(
605 " check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
606 );
607 script.push_str(" }\n");
608
609 script.push_str(" });\n\n");
610 }
611
612 fn generate_schema_types_group(&self, script: &mut String) {
613 script.push_str(" group('Schema Types', function () {\n");
614
615 let types = [
616 ("string", r#"{ "value": "hello" }"#, "schema:string"),
617 ("integer", r#"{ "value": 42 }"#, "schema:integer"),
618 ("number", r#"{ "value": 3.14 }"#, "schema:number"),
619 ("boolean", r#"{ "value": true }"#, "schema:boolean"),
620 ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
621 ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
622 ];
623
624 for (type_name, body, check_name) in types {
625 script.push_str(" {\n");
626 let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
627 let body_str = format!("'{}'", body);
628 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
629 script.push_str(&format!(
630 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
631 check_name
632 ));
633 script.push_str(" }\n");
634 }
635
636 script.push_str(" });\n\n");
637 }
638
639 fn generate_composition_group(&self, script: &mut String) {
640 script.push_str(" group('Composition', function () {\n");
641
642 let compositions = [
643 ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
644 ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
645 ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
646 ];
647
648 for (kind, body, check_name) in compositions {
649 script.push_str(" {\n");
650 let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
651 let body_str = format!("'{}'", body);
652 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
653 script.push_str(&format!(
654 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
655 check_name
656 ));
657 script.push_str(" }\n");
658 }
659
660 script.push_str(" });\n\n");
661 }
662
663 fn generate_string_formats_group(&self, script: &mut String) {
664 script.push_str(" group('String Formats', function () {\n");
665
666 let formats = [
667 ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
668 ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
669 ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
670 ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
671 ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
672 ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
673 ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
674 ];
675
676 for (fmt, body, check_name) in formats {
677 script.push_str(" {\n");
678 let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
679 let body_str = format!("'{}'", body);
680 self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
681 script.push_str(&format!(
682 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
683 check_name
684 ));
685 script.push_str(" }\n");
686 }
687
688 script.push_str(" });\n\n");
689 }
690
691 fn generate_constraints_group(&self, script: &mut String) {
692 script.push_str(" group('Constraints', function () {\n");
693
694 let constraints = [
695 (
696 "required",
697 "JSON.stringify({ required_field: 'present' })",
698 "constraint:required",
699 ),
700 ("optional", "JSON.stringify({})", "constraint:optional"),
701 ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
702 ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
703 ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
704 ];
705
706 for (kind, body, check_name) in constraints {
707 script.push_str(" {\n");
708 let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
709 self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
710 script.push_str(&format!(
711 " check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
712 check_name
713 ));
714 script.push_str(" }\n");
715 }
716
717 script.push_str(" });\n\n");
718 }
719
720 fn generate_response_codes_group(&self, script: &mut String) {
721 script.push_str(" group('Response Codes', function () {\n");
722
723 let codes = [
724 ("200", "response:200"),
725 ("201", "response:201"),
726 ("204", "response:204"),
727 ("400", "response:400"),
728 ("404", "response:404"),
729 ];
730
731 for (code, check_name) in codes {
732 script.push_str(" {\n");
733 let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
734 self.emit_get(script, &url, None);
735 script.push_str(&format!(
736 " check(res, {{ '{}': (r) => r.status === {} }});\n",
737 check_name, code
738 ));
739 script.push_str(" }\n");
740 }
741
742 script.push_str(" });\n\n");
743 }
744
745 fn generate_http_methods_group(&self, script: &mut String) {
746 script.push_str(" group('HTTP Methods', function () {\n");
747
748 script.push_str(" {\n");
750 self.emit_get(script, "${BASE_URL}/conformance/methods", None);
751 script.push_str(
752 " check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
753 );
754 script.push_str(" }\n");
755
756 script.push_str(" {\n");
758 self.emit_post_like(
759 script,
760 "post",
761 "${BASE_URL}/conformance/methods",
762 "JSON.stringify({ action: 'create' })",
763 "JSON_HEADERS",
764 );
765 script.push_str(
766 " check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
767 );
768 script.push_str(" }\n");
769
770 script.push_str(" {\n");
772 self.emit_post_like(
773 script,
774 "put",
775 "${BASE_URL}/conformance/methods",
776 "JSON.stringify({ action: 'update' })",
777 "JSON_HEADERS",
778 );
779 script.push_str(
780 " check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
781 );
782 script.push_str(" }\n");
783
784 script.push_str(" {\n");
786 self.emit_post_like(
787 script,
788 "patch",
789 "${BASE_URL}/conformance/methods",
790 "JSON.stringify({ action: 'patch' })",
791 "JSON_HEADERS",
792 );
793 script.push_str(
794 " check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
795 );
796 script.push_str(" }\n");
797
798 script.push_str(" {\n");
800 self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
801 script.push_str(
802 " check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
803 );
804 script.push_str(" }\n");
805
806 script.push_str(" {\n");
808 self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
809 script.push_str(
810 " check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
811 );
812 script.push_str(" }\n");
813
814 script.push_str(" {\n");
816 self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
817 script.push_str(
818 " check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
819 );
820 script.push_str(" }\n");
821
822 script.push_str(" });\n\n");
823 }
824
825 fn generate_content_negotiation_group(&self, script: &mut String) {
826 script.push_str(" group('Content Types', function () {\n");
827
828 script.push_str(" {\n");
829 self.emit_get(
830 script,
831 "${BASE_URL}/conformance/content-types",
832 Some("{ 'Accept': 'application/json' }"),
833 );
834 script.push_str(
835 " check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
836 );
837 script.push_str(" }\n");
838
839 script.push_str(" });\n\n");
840 }
841
842 fn generate_security_group(&self, script: &mut String) {
843 script.push_str(" group('Security', function () {\n");
844
845 script.push_str(" {\n");
847 self.emit_get(
848 script,
849 "${BASE_URL}/conformance/security/bearer",
850 Some("{ 'Authorization': 'Bearer test-token-123' }"),
851 );
852 script.push_str(
853 " check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
854 );
855 script.push_str(" }\n");
856
857 let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
859 script.push_str(" {\n");
860 let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
861 self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
862 script.push_str(
863 " check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
864 );
865 script.push_str(" }\n");
866
867 let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
869 let encoded = base64_encode(basic_creds);
870 script.push_str(" {\n");
871 let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
872 self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
873 script.push_str(
874 " check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
875 );
876 script.push_str(" }\n");
877
878 script.push_str(" });\n\n");
879 }
880
881 fn generate_handle_summary(&self, script: &mut String) {
882 let report_path = match &self.config.output_dir {
885 Some(dir) => {
886 let abs = std::fs::canonicalize(dir)
887 .unwrap_or_else(|_| dir.clone())
888 .join("conformance-report.json");
889 abs.to_string_lossy().to_string()
890 }
891 None => "conformance-report.json".to_string(),
892 };
893
894 script.push_str("export function handleSummary(data) {\n");
895 script.push_str(" // Extract check results for conformance reporting\n");
896 script.push_str(" let checks = {};\n");
897 script.push_str(" if (data.metrics && data.metrics.checks) {\n");
898 script.push_str(" // Overall check pass rate\n");
899 script.push_str(" checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
900 script.push_str(" }\n");
901 script.push_str(" // Collect per-check results from root_group\n");
902 script.push_str(" let checkResults = {};\n");
903 script.push_str(" function walkGroups(group) {\n");
904 script.push_str(" if (group.checks) {\n");
905 script.push_str(" for (let checkObj of group.checks) {\n");
906 script.push_str(" checkResults[checkObj.name] = {\n");
907 script.push_str(" passes: checkObj.passes,\n");
908 script.push_str(" fails: checkObj.fails,\n");
909 script.push_str(" };\n");
910 script.push_str(" }\n");
911 script.push_str(" }\n");
912 script.push_str(" if (group.groups) {\n");
913 script.push_str(" for (let subGroup of group.groups) {\n");
914 script.push_str(" walkGroups(subGroup);\n");
915 script.push_str(" }\n");
916 script.push_str(" }\n");
917 script.push_str(" }\n");
918 script.push_str(" if (data.root_group) {\n");
919 script.push_str(" walkGroups(data.root_group);\n");
920 script.push_str(" }\n");
921 script.push_str(" let result = {\n");
922 script.push_str(&format!(
923 " '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
924 report_path
925 ));
926 script.push_str(" 'summary.json': JSON.stringify(data),\n");
927 script.push_str(" stdout: textSummary(data, { indent: ' ', enableColors: true }),\n");
928 script.push_str(" };\n");
929 script.push_str(" return result;\n");
930 script.push_str("}\n\n");
931 script.push_str("// textSummary fallback\n");
932 script.push_str("function textSummary(data, opts) {\n");
933 script.push_str(" return JSON.stringify(data, null, 2);\n");
934 script.push_str("}\n");
935 }
936}
937
938fn base64_encode(input: &str) -> String {
940 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
941 let bytes = input.as_bytes();
942 let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
943 for chunk in bytes.chunks(3) {
944 let b0 = chunk[0] as u32;
945 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
946 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
947 let triple = (b0 << 16) | (b1 << 8) | b2;
948 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
949 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
950 if chunk.len() > 1 {
951 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
952 } else {
953 result.push('=');
954 }
955 if chunk.len() > 2 {
956 result.push(CHARS[(triple & 0x3F) as usize] as char);
957 } else {
958 result.push('=');
959 }
960 }
961 result
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 #[test]
969 fn test_generate_conformance_script() {
970 let config = ConformanceConfig {
971 target_url: "http://localhost:8080".to_string(),
972 api_key: None,
973 basic_auth: None,
974 skip_tls_verify: false,
975 categories: None,
976 base_path: None,
977 custom_headers: vec![],
978 output_dir: None,
979 all_operations: false,
980 custom_checks_file: None,
981 request_delay_ms: 0,
982 custom_filter: None,
983 export_requests: false,
984 validate_requests: false,
985 };
986 let generator = ConformanceGenerator::new(config);
987 let script = generator.generate().unwrap();
988
989 assert!(script.contains("import http from 'k6/http'"));
990 assert!(script.contains("vus: 1"));
991 assert!(script.contains("iterations: 1"));
992 assert!(script.contains("group('Parameters'"));
993 assert!(script.contains("group('Request Bodies'"));
994 assert!(script.contains("group('Schema Types'"));
995 assert!(script.contains("group('Composition'"));
996 assert!(script.contains("group('String Formats'"));
997 assert!(script.contains("group('Constraints'"));
998 assert!(script.contains("group('Response Codes'"));
999 assert!(script.contains("group('HTTP Methods'"));
1000 assert!(script.contains("group('Content Types'"));
1001 assert!(script.contains("group('Security'"));
1002 assert!(script.contains("handleSummary"));
1003 }
1004
1005 #[test]
1006 fn test_base64_encode() {
1007 assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
1008 assert_eq!(base64_encode("a"), "YQ==");
1009 assert_eq!(base64_encode("ab"), "YWI=");
1010 assert_eq!(base64_encode("abc"), "YWJj");
1011 }
1012
1013 #[test]
1014 fn test_conformance_script_with_custom_auth() {
1015 let config = ConformanceConfig {
1016 target_url: "https://api.example.com".to_string(),
1017 api_key: Some("my-api-key".to_string()),
1018 basic_auth: Some("admin:secret".to_string()),
1019 skip_tls_verify: true,
1020 categories: None,
1021 base_path: None,
1022 custom_headers: vec![],
1023 output_dir: None,
1024 all_operations: false,
1025 custom_checks_file: None,
1026 request_delay_ms: 0,
1027 custom_filter: None,
1028 export_requests: false,
1029 validate_requests: false,
1030 };
1031 let generator = ConformanceGenerator::new(config);
1032 let script = generator.generate().unwrap();
1033
1034 assert!(script.contains("insecureSkipTLSVerify: true"));
1035 assert!(script.contains("my-api-key"));
1036 assert!(script.contains(&base64_encode("admin:secret")));
1037 }
1038
1039 #[test]
1040 fn test_should_include_category_none_includes_all() {
1041 let config = ConformanceConfig {
1042 target_url: "http://localhost:8080".to_string(),
1043 api_key: None,
1044 basic_auth: None,
1045 skip_tls_verify: false,
1046 categories: None,
1047 base_path: None,
1048 custom_headers: vec![],
1049 output_dir: None,
1050 all_operations: false,
1051 custom_checks_file: None,
1052 request_delay_ms: 0,
1053 custom_filter: None,
1054 export_requests: false,
1055 validate_requests: false,
1056 };
1057 assert!(config.should_include_category("Parameters"));
1058 assert!(config.should_include_category("Security"));
1059 assert!(config.should_include_category("Anything"));
1060 }
1061
1062 #[test]
1063 fn test_should_include_category_filtered() {
1064 let config = ConformanceConfig {
1065 target_url: "http://localhost:8080".to_string(),
1066 api_key: None,
1067 basic_auth: None,
1068 skip_tls_verify: false,
1069 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1070 base_path: None,
1071 custom_headers: vec![],
1072 output_dir: None,
1073 all_operations: false,
1074 custom_checks_file: None,
1075 request_delay_ms: 0,
1076 custom_filter: None,
1077 export_requests: false,
1078 validate_requests: false,
1079 };
1080 assert!(config.should_include_category("Parameters"));
1081 assert!(config.should_include_category("Security"));
1082 assert!(config.should_include_category("parameters")); assert!(!config.should_include_category("Composition"));
1084 assert!(!config.should_include_category("Schema Types"));
1085 }
1086
1087 #[test]
1088 fn test_generate_with_category_filter() {
1089 let config = ConformanceConfig {
1090 target_url: "http://localhost:8080".to_string(),
1091 api_key: None,
1092 basic_auth: None,
1093 skip_tls_verify: false,
1094 categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1095 base_path: None,
1096 custom_headers: vec![],
1097 output_dir: None,
1098 all_operations: false,
1099 custom_checks_file: None,
1100 request_delay_ms: 0,
1101 custom_filter: None,
1102 export_requests: false,
1103 validate_requests: false,
1104 };
1105 let generator = ConformanceGenerator::new(config);
1106 let script = generator.generate().unwrap();
1107
1108 assert!(script.contains("group('Parameters'"));
1109 assert!(script.contains("group('Security'"));
1110 assert!(!script.contains("group('Request Bodies'"));
1111 assert!(!script.contains("group('Schema Types'"));
1112 assert!(!script.contains("group('Composition'"));
1113 }
1114
1115 #[test]
1116 fn test_effective_base_url_no_base_path() {
1117 let config = ConformanceConfig {
1118 target_url: "https://example.com".to_string(),
1119 api_key: None,
1120 basic_auth: None,
1121 skip_tls_verify: false,
1122 categories: None,
1123 base_path: None,
1124 custom_headers: vec![],
1125 output_dir: None,
1126 all_operations: false,
1127 custom_checks_file: None,
1128 request_delay_ms: 0,
1129 custom_filter: None,
1130 export_requests: false,
1131 validate_requests: false,
1132 };
1133 assert_eq!(config.effective_base_url(), "https://example.com");
1134 }
1135
1136 #[test]
1137 fn test_effective_base_url_with_base_path() {
1138 let config = ConformanceConfig {
1139 target_url: "https://example.com".to_string(),
1140 api_key: None,
1141 basic_auth: None,
1142 skip_tls_verify: false,
1143 categories: None,
1144 base_path: Some("/api".to_string()),
1145 custom_headers: vec![],
1146 output_dir: None,
1147 all_operations: false,
1148 custom_checks_file: None,
1149 request_delay_ms: 0,
1150 custom_filter: None,
1151 export_requests: false,
1152 validate_requests: false,
1153 };
1154 assert_eq!(config.effective_base_url(), "https://example.com/api");
1155 }
1156
1157 #[test]
1158 fn test_effective_base_url_trailing_slash_normalization() {
1159 let config = ConformanceConfig {
1160 target_url: "https://example.com/".to_string(),
1161 api_key: None,
1162 basic_auth: None,
1163 skip_tls_verify: false,
1164 categories: None,
1165 base_path: Some("/api".to_string()),
1166 custom_headers: vec![],
1167 output_dir: None,
1168 all_operations: false,
1169 custom_checks_file: None,
1170 request_delay_ms: 0,
1171 custom_filter: None,
1172 export_requests: false,
1173 validate_requests: false,
1174 };
1175 assert_eq!(config.effective_base_url(), "https://example.com/api");
1176 }
1177
1178 #[test]
1179 fn test_effective_base_url_trailing_slash_no_base_path() {
1180 let config = ConformanceConfig {
1183 target_url: "https://192.168.2.86/".to_string(),
1184 api_key: None,
1185 basic_auth: None,
1186 skip_tls_verify: false,
1187 categories: None,
1188 base_path: None,
1189 custom_headers: vec![],
1190 output_dir: None,
1191 all_operations: false,
1192 custom_checks_file: None,
1193 request_delay_ms: 0,
1194 custom_filter: None,
1195 export_requests: false,
1196 validate_requests: false,
1197 };
1198 assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1199 }
1200
1201 #[test]
1202 fn test_generate_script_with_base_path() {
1203 let config = ConformanceConfig {
1204 target_url: "https://192.168.2.86".to_string(),
1205 api_key: None,
1206 basic_auth: None,
1207 skip_tls_verify: true,
1208 categories: None,
1209 base_path: Some("/api".to_string()),
1210 custom_headers: vec![],
1211 output_dir: None,
1212 all_operations: false,
1213 custom_checks_file: None,
1214 request_delay_ms: 0,
1215 custom_filter: None,
1216 export_requests: false,
1217 validate_requests: false,
1218 };
1219 let generator = ConformanceGenerator::new(config);
1220 let script = generator.generate().unwrap();
1221
1222 assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1223 assert!(script.contains("${BASE_URL}/conformance/"));
1225 }
1226
1227 #[test]
1228 fn test_generate_with_custom_headers() {
1229 let config = ConformanceConfig {
1230 target_url: "https://192.168.2.86".to_string(),
1231 api_key: None,
1232 basic_auth: None,
1233 skip_tls_verify: true,
1234 categories: Some(vec!["Parameters".to_string()]),
1235 base_path: Some("/api".to_string()),
1236 custom_headers: vec![
1237 ("X-Avi-Tenant".to_string(), "admin".to_string()),
1238 ("X-CSRFToken".to_string(), "real-token".to_string()),
1239 ],
1240 output_dir: None,
1241 all_operations: false,
1242 custom_checks_file: None,
1243 request_delay_ms: 0,
1244 custom_filter: None,
1245 export_requests: false,
1246 validate_requests: false,
1247 };
1248 let generator = ConformanceGenerator::new(config);
1249 let script = generator.generate().unwrap();
1250
1251 assert!(
1253 !script.contains("const CUSTOM_HEADERS"),
1254 "Script should NOT declare a CUSTOM_HEADERS const"
1255 );
1256 assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1257 assert!(script.contains("'X-CSRFToken': 'real-token'"));
1258 }
1259
1260 #[test]
1261 fn test_custom_headers_js_object() {
1262 let config = ConformanceConfig {
1263 target_url: "http://localhost".to_string(),
1264 api_key: None,
1265 basic_auth: None,
1266 skip_tls_verify: false,
1267 categories: None,
1268 base_path: None,
1269 custom_headers: vec![
1270 ("Authorization".to_string(), "Bearer abc123".to_string()),
1271 ("X-Custom".to_string(), "value".to_string()),
1272 ],
1273 output_dir: None,
1274 all_operations: false,
1275 custom_checks_file: None,
1276 request_delay_ms: 0,
1277 custom_filter: None,
1278 export_requests: false,
1279 validate_requests: false,
1280 };
1281 let js = config.custom_headers_js_object();
1282 assert!(js.contains("'Authorization': 'Bearer abc123'"));
1283 assert!(js.contains("'X-Custom': 'value'"));
1284 }
1285}