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