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