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