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