mockforge_bench/conformance/
report.rs1use super::spec::ConformanceFeature;
4use crate::error::{BenchError, Result};
5use crate::owasp_api::categories::OwaspCategory;
6use colored::*;
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10#[derive(Debug, Clone, Default)]
12pub struct CategoryResult {
13 pub passed: usize,
14 pub failed: usize,
15}
16
17impl CategoryResult {
18 pub fn total(&self) -> usize {
19 self.passed + self.failed
20 }
21
22 pub fn rate(&self) -> f64 {
23 if self.total() == 0 {
24 0.0
25 } else {
26 (self.passed as f64 / self.total() as f64) * 100.0
27 }
28 }
29}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct FailureDetail {
34 pub check: String,
36 pub request: FailureRequest,
38 pub response: FailureResponse,
40 pub expected: String,
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct FailureRequest {
47 #[serde(default)]
49 pub method: String,
50 #[serde(default)]
52 pub url: String,
53 #[serde(default, deserialize_with = "deserialize_headers")]
55 pub headers: HashMap<String, String>,
56 #[serde(default)]
58 pub body: String,
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct FailureResponse {
64 #[serde(default)]
66 pub status: u16,
67 #[serde(default, deserialize_with = "deserialize_headers")]
69 pub headers: HashMap<String, String>,
70 #[serde(default)]
72 pub body: String,
73}
74
75fn deserialize_headers<'de, D>(
77 deserializer: D,
78) -> std::result::Result<HashMap<String, String>, D::Error>
79where
80 D: serde::Deserializer<'de>,
81{
82 use serde::Deserialize;
83 let map: HashMap<String, serde_json::Value> = HashMap::deserialize(deserializer)?;
84 Ok(map
85 .into_iter()
86 .map(|(k, v)| {
87 let val = match &v {
88 serde_json::Value::String(s) => s.clone(),
89 serde_json::Value::Array(arr) => {
90 arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
91 }
92 other => other.to_string(),
93 };
94 (k, val)
95 })
96 .collect())
97}
98
99pub struct ConformanceReport {
101 check_results: HashMap<String, (u64, u64)>,
103 failure_details: Vec<FailureDetail>,
105}
106
107impl ConformanceReport {
108 pub fn from_file(path: &Path) -> Result<Self> {
112 let content = std::fs::read_to_string(path)
113 .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
114 let mut report = Self::from_json(&content)?;
115
116 if let Some(parent) = path.parent() {
118 let details_path = parent.join("conformance-failure-details.json");
119 if details_path.exists() {
120 if let Ok(details_json) = std::fs::read_to_string(&details_path) {
121 if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
122 report.failure_details = details;
123 }
124 }
125 }
126 }
127
128 Ok(report)
129 }
130
131 pub fn from_json(json_str: &str) -> Result<Self> {
133 let json: serde_json::Value = serde_json::from_str(json_str)
134 .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
135
136 let mut check_results = HashMap::new();
137
138 if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
139 for (name, result) in checks {
140 let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
141 let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
142 check_results.insert(name.clone(), (passes, fails));
143 }
144 }
145
146 Ok(Self {
147 check_results,
148 failure_details: Vec::new(),
149 })
150 }
151
152 pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
154 let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
155
156 for cat in ConformanceFeature::categories() {
158 categories.insert(cat, CategoryResult::default());
159 }
160
161 for feature in ConformanceFeature::all() {
165 let check_name = feature.check_name();
166 let category = feature.category();
167
168 let entry = categories.entry(category).or_default();
169
170 if let Some((passes, fails)) = self.check_results.get(check_name) {
172 if *fails == 0 && *passes > 0 {
173 entry.passed += 1;
174 } else {
175 entry.failed += 1;
176 }
177 } else {
178 let prefix = format!("{}:", check_name);
180 for (name, (passes, fails)) in &self.check_results {
181 if name.starts_with(&prefix) {
182 if *fails == 0 && *passes > 0 {
183 entry.passed += 1;
184 } else {
185 entry.failed += 1;
186 }
187 }
188 }
189 }
191 }
192
193 categories
194 }
195
196 pub fn print_report(&self) {
198 self.print_report_with_options(false);
199 }
200
201 pub fn print_report_with_options(&self, all_operations: bool) {
203 let categories = self.by_category();
204
205 println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
206 println!("{}", "=".repeat(64).bright_green());
207
208 println!(
209 "{:<20} {:>8} {:>8} {:>8} {:>8}",
210 "Category".bold(),
211 "Passed".green().bold(),
212 "Failed".red().bold(),
213 "Total".bold(),
214 "Rate".bold()
215 );
216 println!("{}", "-".repeat(64));
217
218 let mut total_passed = 0usize;
219 let mut total_failed = 0usize;
220
221 for cat_name in ConformanceFeature::categories() {
222 if let Some(result) = categories.get(cat_name) {
223 let total = result.total();
224 if total == 0 {
225 continue;
226 }
227 total_passed += result.passed;
228 total_failed += result.failed;
229
230 let rate_str = format!("{:.0}%", result.rate());
231 let rate_colored = if result.rate() >= 100.0 {
232 rate_str.green()
233 } else if result.rate() >= 80.0 {
234 rate_str.yellow()
235 } else {
236 rate_str.red()
237 };
238
239 println!(
240 "{:<20} {:>8} {:>8} {:>8} {:>8}",
241 cat_name,
242 result.passed.to_string().green(),
243 result.failed.to_string().red(),
244 total,
245 rate_colored
246 );
247 }
248 }
249
250 println!("{}", "=".repeat(64).bright_green());
251
252 let grand_total = total_passed + total_failed;
253 let overall_rate = if grand_total > 0 {
254 (total_passed as f64 / grand_total as f64) * 100.0
255 } else {
256 0.0
257 };
258 let rate_str = format!("{:.0}%", overall_rate);
259 let rate_colored = if overall_rate >= 100.0 {
260 rate_str.green()
261 } else if overall_rate >= 80.0 {
262 rate_str.yellow()
263 } else {
264 rate_str.red()
265 };
266
267 println!(
268 "{:<20} {:>8} {:>8} {:>8} {:>8}",
269 "Total:".bold(),
270 total_passed.to_string().green(),
271 total_failed.to_string().red(),
272 grand_total,
273 rate_colored
274 );
275
276 let failed_checks: Vec<_> =
278 self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
279
280 if !failed_checks.is_empty() {
281 println!();
282 println!("{}", "Failed Checks:".red().bold());
283 let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
284 sorted_failures.sort_by_key(|(name, _)| (*name).clone());
285 for (name, (passes, fails)) in sorted_failures {
286 println!(
287 " {} ({} passed, {} failed)",
288 name.red(),
289 passes.to_string().green(),
290 fails.to_string().red()
291 );
292
293 for detail in &self.failure_details {
295 if detail.check == *name {
296 println!(
297 " {} {} {}",
298 "→".bright_black(),
299 detail.request.method.yellow(),
300 detail.request.url.bright_black()
301 );
302 println!(
303 " Expected: {} Actual status: {}",
304 detail.expected.yellow(),
305 detail.response.status.to_string().red()
306 );
307 if !detail.response.body.is_empty() {
308 let body_preview = if detail.response.body.len() > 200 {
309 format!("{}...", &detail.response.body[..200])
310 } else {
311 detail.response.body.clone()
312 };
313 println!(" Response body: {}", body_preview.bright_black());
314 }
315 }
316 }
317 }
318
319 if !all_operations {
320 println!();
321 println!(
322 "{}",
323 "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
324 .yellow()
325 );
326 }
327
328 if !self.failure_details.is_empty() {
329 println!();
330 println!(
331 "{}",
332 "Full failure details saved to conformance-report.json (see failure_details array)."
333 .bright_black()
334 );
335 }
336 }
337
338 self.print_owasp_coverage();
340
341 println!();
342 }
343
344 fn print_owasp_coverage(&self) {
346 println!();
347 println!("{}", "OWASP API Security Top 10 Coverage".bold());
348 println!("{}", "=".repeat(64).bright_green());
349
350 let mut feature_status: HashMap<&str, bool> = HashMap::new(); for feature in ConformanceFeature::all() {
353 let check_name = feature.check_name();
354
355 if let Some((passes, fails)) = self.check_results.get(check_name) {
357 let passed = *fails == 0 && *passes > 0;
358 feature_status
359 .entry(check_name)
360 .and_modify(|prev| *prev = *prev && passed)
361 .or_insert(passed);
362 } else {
363 let prefix = format!("{}:", check_name);
365 for (name, (passes, fails)) in &self.check_results {
366 if name.starts_with(&prefix) {
367 let passed = *fails == 0 && *passes > 0;
368 feature_status
369 .entry(check_name)
370 .and_modify(|prev| *prev = *prev && passed)
371 .or_insert(passed);
372 }
373 }
374 }
375 }
376
377 for category in OwaspCategory::all() {
378 let id = category.identifier();
379 let name = category.short_name();
380
381 let mut tested = false;
383 let mut all_passed = true;
384 let mut via_categories: HashSet<&str> = HashSet::new();
385
386 for feature in ConformanceFeature::all() {
387 if !feature.related_owasp().contains(&id) {
388 continue;
389 }
390 if let Some(&passed) = feature_status.get(feature.check_name()) {
391 tested = true;
392 if !passed {
393 all_passed = false;
394 }
395 via_categories.insert(feature.category());
396 }
397 }
398
399 let (status, via) = if !tested {
400 ("-".bright_black(), String::new())
401 } else {
402 let mut cats: Vec<&str> = via_categories.into_iter().collect();
403 cats.sort();
404 let via_str = format!(" (via {})", cats.join(", "));
405 if all_passed {
406 ("✓".green(), via_str)
407 } else {
408 ("⚠".yellow(), format!("{} — has failures", via_str))
409 }
410 };
411
412 println!(" {:<12} {:<40} {}{}", id, name, status, via);
413 }
414 }
415
416 pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
418 &self.check_results
419 }
420
421 pub fn overall_rate(&self) -> f64 {
423 let categories = self.by_category();
424 let total_passed: usize = categories.values().map(|r| r.passed).sum();
425 let total: usize = categories.values().map(|r| r.total()).sum();
426 if total == 0 {
427 0.0
428 } else {
429 (total_passed as f64 / total as f64) * 100.0
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn test_parse_conformance_report() {
440 let json = r#"{
441 "checks": {
442 "param:path:string": { "passes": 1, "fails": 0 },
443 "param:path:integer": { "passes": 1, "fails": 0 },
444 "body:json": { "passes": 0, "fails": 1 },
445 "method:GET": { "passes": 1, "fails": 0 }
446 },
447 "overall": { "overall_pass_rate": 0.75 }
448 }"#;
449
450 let report = ConformanceReport::from_json(json).unwrap();
451 let categories = report.by_category();
452
453 let params = categories.get("Parameters").unwrap();
454 assert_eq!(params.passed, 2);
455
456 let bodies = categories.get("Request Bodies").unwrap();
457 assert_eq!(bodies.failed, 1);
458 }
459
460 #[test]
461 fn test_empty_report() {
462 let json = r#"{ "checks": {} }"#;
463 let report = ConformanceReport::from_json(json).unwrap();
464 assert_eq!(report.overall_rate(), 0.0);
465 }
466
467 #[test]
468 fn test_owasp_coverage_with_failures() {
469 let json = r#"{
473 "checks": {
474 "response:404": { "passes": 0, "fails": 1 },
475 "body:json": { "passes": 1, "fails": 0 },
476 "method:GET": { "passes": 1, "fails": 0 }
477 },
478 "overall": {}
479 }"#;
480
481 let report = ConformanceReport::from_json(json).unwrap();
482 report.print_report_with_options(false);
484 }
485}