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