Skip to main content

mockforge_bench/conformance/
report.rs

1//! Conformance test report parsing and display
2
3use 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/// Per-category conformance result
11#[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/// Detail of a single conformance check failure
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct FailureDetail {
34    /// Check name that failed
35    pub check: String,
36    /// Request information
37    pub request: FailureRequest,
38    /// Response information
39    pub response: FailureResponse,
40    /// What the check expected
41    pub expected: String,
42}
43
44/// Request details for a failed check
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct FailureRequest {
47    /// HTTP method
48    #[serde(default)]
49    pub method: String,
50    /// Full URL
51    #[serde(default)]
52    pub url: String,
53    /// Request headers (k6 sends arrays per header, we flatten to first value)
54    #[serde(default, deserialize_with = "deserialize_headers")]
55    pub headers: HashMap<String, String>,
56    /// Request body (truncated)
57    #[serde(default)]
58    pub body: String,
59}
60
61/// Response details for a failed check
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct FailureResponse {
64    /// HTTP status code
65    #[serde(default)]
66    pub status: u16,
67    /// Response headers (k6 may send arrays or strings)
68    #[serde(default, deserialize_with = "deserialize_headers")]
69    pub headers: HashMap<String, String>,
70    /// Response body (truncated)
71    #[serde(default)]
72    pub body: String,
73}
74
75/// Deserialize headers that may be `{key: "value"}` or `{key: ["value"]}` (k6 format)
76fn 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
99/// Conformance test report
100pub struct ConformanceReport {
101    /// Per-check results: check_name -> (passes, fails)
102    check_results: HashMap<String, (u64, u64)>,
103    /// Detailed failure information
104    failure_details: Vec<FailureDetail>,
105}
106
107impl ConformanceReport {
108    /// Parse a conformance report from k6's handleSummary JSON output
109    ///
110    /// Also loads failure details from `conformance-failure-details.json` in the same directory.
111    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        // Load failure details from sibling file
117        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    /// Parse from JSON string
132    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    /// Get results grouped by category
153    pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
154        let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
155
156        // Initialize all categories
157        for cat in ConformanceFeature::categories() {
158            categories.insert(cat, CategoryResult::default());
159        }
160
161        // Map check results to features.
162        // Check names are path-qualified (e.g. "constraint:required:/users")
163        // so we match by prefix as well as exact name.
164        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            // First try exact match (reference mode)
171            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                // Try prefix match (path-qualified: "constraint:required:/path")
179                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                // Features not in results are not counted
190            }
191        }
192
193        categories
194    }
195
196    /// Print the conformance report to stdout
197    pub fn print_report(&self) {
198        self.print_report_with_options(false);
199    }
200
201    /// Print the conformance report with options controlling detail level
202    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        // Print failed checks detail section
277        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                // Show failure details if available
294                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        // OWASP API Top 10 coverage section
339        self.print_owasp_coverage();
340
341        println!();
342    }
343
344    /// Print OWASP API Security Top 10 coverage based on tested features
345    fn print_owasp_coverage(&self) {
346        println!();
347        println!("{}", "OWASP API Security Top 10 Coverage".bold());
348        println!("{}", "=".repeat(64).bright_green());
349
350        // Build a map of feature check_name → passed/failed status
351        let mut feature_status: HashMap<&str, bool> = HashMap::new(); // true = all passed
352        for feature in ConformanceFeature::all() {
353            let check_name = feature.check_name();
354
355            // Exact match (reference mode)
356            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                // Prefix match (path-qualified check names)
364                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            // Find features that map to this OWASP category and were tested
382            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    /// Get raw per-check results (for SARIF conversion)
417    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
418        &self.check_results
419    }
420
421    /// Overall pass rate (0.0 - 100.0)
422    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        // response:404 maps to API8 + API9, body:json maps to API4 + API8
470        // response:404 fails, so API8 and API9 should show as having failures
471        // body:json passes, so API4 should show as passing
472        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        // Print the report to verify visually (--nocapture)
483        report.print_report_with_options(false);
484    }
485}