Skip to main content

mockforge_bench/conformance/
report.rs

1//! Conformance test report parsing and display
2
3use super::executor::SchemaViolation;
4use super::spec::ConformanceFeature;
5use crate::error::{BenchError, Result};
6use crate::owasp_api::categories::OwaspCategory;
7use colored::*;
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11/// OWASP API Security Top 10 coverage entry for a single OWASP category
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct OwaspCoverageEntry {
14    /// OWASP identifier (e.g., "API1:2023")
15    pub id: String,
16    /// Short name (e.g., "Broken Object Level Authorization")
17    pub name: String,
18    /// Whether any conformance checks mapped to this category were tested
19    pub tested: bool,
20    /// Whether all related checks passed (only meaningful if `tested` is true)
21    pub all_passed: bool,
22    /// Which conformance categories contributed to this OWASP category's coverage
23    pub via_categories: Vec<String>,
24}
25
26/// Per-category conformance result
27#[derive(Debug, Clone, Default)]
28pub struct CategoryResult {
29    pub passed: usize,
30    pub failed: usize,
31}
32
33impl CategoryResult {
34    pub fn total(&self) -> usize {
35        self.passed + self.failed
36    }
37
38    pub fn rate(&self) -> f64 {
39        if self.total() == 0 {
40            0.0
41        } else {
42            (self.passed as f64 / self.total() as f64) * 100.0
43        }
44    }
45}
46
47/// Detail of a single conformance check failure
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct FailureDetail {
50    /// Check name that failed
51    pub check: String,
52    /// Request information
53    pub request: FailureRequest,
54    /// Response information
55    pub response: FailureResponse,
56    /// What the check expected
57    pub expected: String,
58    /// Field-level schema validation violations (empty for non-schema checks)
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub schema_violations: Vec<SchemaViolation>,
61}
62
63/// Request details for a failed check
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct FailureRequest {
66    /// HTTP method
67    #[serde(default)]
68    pub method: String,
69    /// Full URL
70    #[serde(default)]
71    pub url: String,
72    /// Request headers (k6 sends arrays per header, we flatten to first value)
73    #[serde(default, deserialize_with = "deserialize_headers")]
74    pub headers: HashMap<String, String>,
75    /// Request body (truncated)
76    #[serde(default)]
77    pub body: String,
78}
79
80/// Response details for a failed check
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct FailureResponse {
83    /// HTTP status code
84    #[serde(default)]
85    pub status: u16,
86    /// Response headers (k6 may send arrays or strings)
87    #[serde(default, deserialize_with = "deserialize_headers")]
88    pub headers: HashMap<String, String>,
89    /// Response body (truncated)
90    #[serde(default)]
91    pub body: String,
92}
93
94/// Deserialize headers that may be `{key: "value"}` or `{key: ["value"]}` (k6 format)
95fn deserialize_headers<'de, D>(
96    deserializer: D,
97) -> std::result::Result<HashMap<String, String>, D::Error>
98where
99    D: serde::Deserializer<'de>,
100{
101    use serde::Deserialize;
102    let map: HashMap<String, serde_json::Value> = HashMap::deserialize(deserializer)?;
103    Ok(map
104        .into_iter()
105        .map(|(k, v)| {
106            let val = match &v {
107                serde_json::Value::String(s) => s.clone(),
108                serde_json::Value::Array(arr) => {
109                    arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")
110                }
111                other => other.to_string(),
112            };
113            (k, val)
114        })
115        .collect())
116}
117
118/// Extract the base name of a custom check.
119/// Custom sub-checks have format "custom:name:header:..." or "custom:name:body:..."
120/// The base name is just the primary check (e.g., "custom:pets-returns-200").
121fn extract_custom_base_name(check_name: &str) -> String {
122    // "custom:" prefix is 7 chars. Find the next colon after that.
123    let after_prefix = &check_name[7..];
124    if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
125        check_name[..7 + pos].to_string()
126    } else {
127        check_name.to_string()
128    }
129}
130
131/// Conformance test report
132pub struct ConformanceReport {
133    /// Per-check results: check_name -> (passes, fails)
134    check_results: HashMap<String, (u64, u64)>,
135    /// Detailed failure information
136    failure_details: Vec<FailureDetail>,
137}
138
139impl ConformanceReport {
140    /// Construct a report directly from check results and failure details.
141    /// Used by `NativeConformanceExecutor` to build a report without k6.
142    pub fn from_results(
143        check_results: HashMap<String, (u64, u64)>,
144        failure_details: Vec<FailureDetail>,
145    ) -> Self {
146        Self {
147            check_results,
148            failure_details,
149        }
150    }
151
152    /// Serialize the report to JSON.
153    ///
154    /// Includes both the raw `checks` map (for CLI/k6 compat) and structured
155    /// `summary`, `categories`, and `failures` fields (for UI consumption).
156    pub fn to_json(&self) -> serde_json::Value {
157        let mut checks = serde_json::Map::new();
158        for (name, (passes, fails)) in &self.check_results {
159            checks.insert(
160                name.clone(),
161                serde_json::json!({
162                    "passes": passes,
163                    "fails": fails,
164                }),
165            );
166        }
167
168        // Compute structured category results for UI
169        let by_cat = self.by_category();
170        let mut categories_json = serde_json::Map::new();
171        for (cat_name, cat_result) in &by_cat {
172            categories_json.insert(
173                (*cat_name).to_string(),
174                serde_json::json!({
175                    "passed": cat_result.passed,
176                    "total": cat_result.total(),
177                    "rate": cat_result.rate(),
178                }),
179            );
180        }
181
182        // Compute summary
183        let total_passed: usize = by_cat.values().map(|r| r.passed).sum();
184        let total: usize = by_cat.values().map(|r| r.total()).sum();
185        let overall_rate = if total == 0 {
186            0.0
187        } else {
188            (total_passed as f64 / total as f64) * 100.0
189        };
190
191        // Transform failure details into UI-friendly format
192        let failures: Vec<serde_json::Value> = self
193            .failure_details
194            .iter()
195            .map(|d| {
196                let category = Self::category_for_check(&d.check);
197                serde_json::json!({
198                    "check_name": d.check,
199                    "category": category,
200                    "expected": d.expected,
201                    "actual": format!("status {}", d.response.status),
202                    "details": format!("{} {}", d.request.method, d.request.url),
203                })
204            })
205            .collect();
206
207        let mut result = serde_json::json!({
208            "checks": checks,
209            "summary": {
210                "total_checks": total,
211                "passed": total_passed,
212                "failed": total - total_passed,
213                "overall_rate": overall_rate,
214            },
215            "categories": categories_json,
216            "failures": failures,
217        });
218
219        // Keep raw failure_details for backward compat
220        if !self.failure_details.is_empty() {
221            result["failure_details"] = serde_json::to_value(&self.failure_details)
222                .unwrap_or(serde_json::Value::Array(Vec::new()));
223        }
224        result
225    }
226
227    /// Determine the category for a check name based on its prefix
228    fn category_for_check(check_name: &str) -> &'static str {
229        let prefix = check_name.split(':').next().unwrap_or("");
230        match prefix {
231            "param" => "Parameters",
232            "body" => "Request Bodies",
233            "response" => "Response Codes",
234            "schema" => "Schema Types",
235            "compose" => "Composition",
236            "format" => "String Formats",
237            "constraint" => "Constraints",
238            "security" => "Security",
239            "method" => "HTTP Methods",
240            "content" => "Content Types",
241            "validation" | "response_validation" => "Response Validation",
242            "custom" => "Custom",
243            _ => "Other",
244        }
245    }
246
247    /// Get the failure details
248    pub fn failure_details(&self) -> &[FailureDetail] {
249        &self.failure_details
250    }
251
252    /// Parse a conformance report from k6's handleSummary JSON output
253    ///
254    /// Also loads failure details from `conformance-failure-details.json` in the same directory.
255    pub fn from_file(path: &Path) -> Result<Self> {
256        let content = std::fs::read_to_string(path)
257            .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
258        let mut report = Self::from_json(&content)?;
259
260        // Load failure details from sibling file
261        if let Some(parent) = path.parent() {
262            let details_path = parent.join("conformance-failure-details.json");
263            if details_path.exists() {
264                if let Ok(details_json) = std::fs::read_to_string(&details_path) {
265                    if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
266                        report.failure_details = details;
267                    }
268                }
269            }
270        }
271
272        Ok(report)
273    }
274
275    /// Parse from JSON string
276    pub fn from_json(json_str: &str) -> Result<Self> {
277        let json: serde_json::Value = serde_json::from_str(json_str)
278            .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
279
280        let mut check_results = HashMap::new();
281
282        if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
283            for (name, result) in checks {
284                let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
285                let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
286                check_results.insert(name.clone(), (passes, fails));
287            }
288        }
289
290        Ok(Self {
291            check_results,
292            failure_details: Vec::new(),
293        })
294    }
295
296    /// Get results grouped by category.
297    ///
298    /// Includes all standard categories plus a synthetic "Custom" category
299    /// for any check names starting with "custom:".
300    pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
301        let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
302
303        // Initialize all categories
304        for cat in ConformanceFeature::categories() {
305            categories.insert(cat, CategoryResult::default());
306        }
307
308        // Map check results to features.
309        // Check names are path-qualified (e.g. "constraint:required:/users")
310        // so we match by prefix as well as exact name.
311        for feature in ConformanceFeature::all() {
312            let check_name = feature.check_name();
313            let category = feature.category();
314
315            let entry = categories.entry(category).or_default();
316
317            // First try exact match (reference mode)
318            if let Some((passes, fails)) = self.check_results.get(check_name) {
319                if *fails == 0 && *passes > 0 {
320                    entry.passed += 1;
321                } else {
322                    entry.failed += 1;
323                }
324            } else {
325                // Try prefix match (path-qualified: "constraint:required:/path")
326                let prefix = format!("{}:", check_name);
327                for (name, (passes, fails)) in &self.check_results {
328                    if name.starts_with(&prefix) {
329                        if *fails == 0 && *passes > 0 {
330                            entry.passed += 1;
331                        } else {
332                            entry.failed += 1;
333                        }
334                    }
335                }
336                // Features not in results are not counted
337            }
338        }
339
340        // Aggregate custom checks (check names starting with "custom:")
341        let custom_entry = categories.entry("Custom").or_default();
342        // Track which top-level custom check names we've already counted
343        let mut counted_custom: HashSet<String> = HashSet::new();
344        for (name, (passes, fails)) in &self.check_results {
345            if name.starts_with("custom:") {
346                // Only count the primary check (status), not sub-checks (header/body)
347                // Sub-checks have format "custom:name:header:..." or "custom:name:body:..."
348                // Primary checks are just "custom:something" with exactly one colon after "custom"
349                // We count each unique top-level custom check once
350                let base_name = extract_custom_base_name(name);
351                if counted_custom.insert(base_name) {
352                    if *fails == 0 && *passes > 0 {
353                        custom_entry.passed += 1;
354                    } else {
355                        custom_entry.failed += 1;
356                    }
357                }
358            }
359        }
360
361        categories
362    }
363
364    /// Print the conformance report to stdout
365    pub fn print_report(&self) {
366        self.print_report_with_options(false);
367    }
368
369    /// Print the conformance report with options controlling detail level
370    pub fn print_report_with_options(&self, all_operations: bool) {
371        let categories = self.by_category();
372
373        // Count detected features and active categories
374        let total_possible = ConformanceFeature::all().len();
375        let active_cats: usize = ConformanceFeature::categories()
376            .iter()
377            .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
378            .count();
379        let detected: usize =
380            categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
381
382        println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
383        println!("{}", "=".repeat(64).bright_green());
384
385        println!(
386            "{}",
387            format!(
388                "Spec Analysis: {} of {} features detected across {} categories",
389                detected, total_possible, active_cats
390            )
391            .bright_cyan()
392        );
393        println!();
394
395        println!(
396            "{:<20} {:>8} {:>8} {:>8} {:>8}",
397            "Category".bold(),
398            "Passed".green().bold(),
399            "Failed".red().bold(),
400            "Total".bold(),
401            "Rate".bold()
402        );
403        println!("{}", "-".repeat(64));
404
405        let mut total_passed = 0usize;
406        let mut total_failed = 0usize;
407        let mut empty_categories: Vec<&str> = Vec::new();
408
409        // Build the list of categories to display (standard + Custom if present)
410        let all_cat_names: Vec<&str> = {
411            let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
412            if categories.get("Custom").is_some_and(|r| r.total() > 0) {
413                cats.push("Custom");
414            }
415            cats
416        };
417
418        for cat_name in &all_cat_names {
419            if let Some(result) = categories.get(cat_name) {
420                let total = result.total();
421                if total == 0 {
422                    // Show empty categories with dimmed "not in spec" indicator
423                    println!(
424                        "{:<20} {:>8} {:>8} {:>8} {:>8}",
425                        cat_name.bright_black(),
426                        "-".bright_black(),
427                        "-".bright_black(),
428                        "-".bright_black(),
429                        "not in spec".bright_black()
430                    );
431                    empty_categories.push(cat_name);
432                    continue;
433                }
434                total_passed += result.passed;
435                total_failed += result.failed;
436
437                let rate_str = format!("{:.0}%", result.rate());
438                let rate_colored = if result.rate() >= 100.0 {
439                    rate_str.green()
440                } else if result.rate() >= 80.0 {
441                    rate_str.yellow()
442                } else {
443                    rate_str.red()
444                };
445
446                println!(
447                    "{:<20} {:>8} {:>8} {:>8} {:>8}",
448                    cat_name,
449                    result.passed.to_string().green(),
450                    result.failed.to_string().red(),
451                    total,
452                    rate_colored
453                );
454            }
455        }
456
457        println!("{}", "=".repeat(64).bright_green());
458
459        let grand_total = total_passed + total_failed;
460        let overall_rate = if grand_total > 0 {
461            (total_passed as f64 / grand_total as f64) * 100.0
462        } else {
463            0.0
464        };
465        let rate_str = format!("{:.0}%", overall_rate);
466        let rate_colored = if overall_rate >= 100.0 {
467            rate_str.green()
468        } else if overall_rate >= 80.0 {
469            rate_str.yellow()
470        } else {
471            rate_str.red()
472        };
473
474        println!(
475            "{:<20} {:>8} {:>8} {:>8} {:>8}",
476            "Total:".bold(),
477            total_passed.to_string().green(),
478            total_failed.to_string().red(),
479            grand_total,
480            rate_colored
481        );
482
483        // Print failed checks detail section
484        let failed_checks: Vec<_> =
485            self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
486
487        if !failed_checks.is_empty() {
488            println!();
489            println!("{}", "Failed Checks:".red().bold());
490            let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
491            sorted_failures.sort_by_key(|(name, _)| (*name).clone());
492            for (name, (passes, fails)) in sorted_failures {
493                println!(
494                    "  {} ({} passed, {} failed)",
495                    name.red(),
496                    passes.to_string().green(),
497                    fails.to_string().red()
498                );
499
500                // Show failure details if available
501                for detail in &self.failure_details {
502                    if detail.check == *name {
503                        println!(
504                            "    {} {} {}",
505                            "→".bright_black(),
506                            detail.request.method.yellow(),
507                            detail.request.url.bright_black()
508                        );
509                        println!(
510                            "      Expected: {}  Actual status: {}",
511                            detail.expected.yellow(),
512                            detail.response.status.to_string().red()
513                        );
514                        if !detail.response.body.is_empty() {
515                            let body_preview = if detail.response.body.len() > 200 {
516                                format!("{}...", &detail.response.body[..200])
517                            } else {
518                                detail.response.body.clone()
519                            };
520                            println!("      Response body: {}", body_preview.bright_black());
521                        }
522                        // Show field-level schema violations if present
523                        if !detail.schema_violations.is_empty() {
524                            println!(
525                                "      {} ({} violation{}):",
526                                "Schema violations".red(),
527                                detail.schema_violations.len(),
528                                if detail.schema_violations.len() == 1 {
529                                    ""
530                                } else {
531                                    "s"
532                                }
533                            );
534                            for violation in &detail.schema_violations {
535                                println!(
536                                    "        {} {}: {} (expected: {}, actual: {})",
537                                    "·".bright_black(),
538                                    violation.field_path.yellow(),
539                                    violation.violation_type.red(),
540                                    violation.expected.bright_black(),
541                                    violation.actual.bright_black()
542                                );
543                            }
544                        }
545                    }
546                }
547            }
548
549            if !all_operations {
550                println!();
551                println!(
552                    "{}",
553                    "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
554                        .yellow()
555                );
556            }
557
558            if !self.failure_details.is_empty() {
559                println!();
560                println!(
561                    "{}",
562                    "Full failure details saved to conformance-report.json (see failure_details array)."
563                        .bright_black()
564                );
565            }
566
567            // Check for 429 rate limiting in failure details
568            let rate_limited_count =
569                self.failure_details.iter().filter(|d| d.response.status == 429).count();
570            if rate_limited_count > 0 {
571                println!();
572                println!(
573                    "{}",
574                    format!(
575                        "Warning: {} check(s) received HTTP 429 (Too Many Requests) from the target API.",
576                        rate_limited_count
577                    )
578                    .yellow()
579                    .bold()
580                );
581                println!(
582                    "{}",
583                    "The target server is rate-limiting requests. Use --conformance-delay <ms> to add a pause"
584                        .yellow()
585                );
586                println!(
587                    "{}",
588                    "between requests (e.g., --conformance-delay 200 for 200ms between each check)."
589                        .yellow()
590                );
591            }
592
593            // Explain what "Failed" means
594            println!();
595            println!(
596                "{}",
597                "Note: \"Failed\" = the API response did not match the expected conformance check"
598                    .bright_black()
599            );
600            println!(
601                "{}",
602                "(e.g., wrong status code, missing field). These are NOT infrastructure errors."
603                    .bright_black()
604            );
605        }
606
607        // OWASP API Top 10 coverage section
608        self.print_owasp_coverage();
609
610        // Coverage tips for empty categories
611        if !empty_categories.is_empty() {
612            println!();
613            println!("{}", "Coverage Tips".bold());
614            println!("{}", "-".repeat(64));
615            for cat in &empty_categories {
616                if *cat == "Custom" {
617                    continue;
618                }
619                println!(
620                    "  {} {}: {}",
621                    "->".bright_cyan(),
622                    cat,
623                    ConformanceFeature::category_hint(cat).bright_black()
624                );
625            }
626            println!();
627            println!(
628                "{}",
629                "Use --conformance-custom <file.yaml> to add custom checks for any category."
630                    .bright_black()
631            );
632        }
633
634        println!();
635    }
636
637    /// Compute OWASP API Security Top 10 coverage as structured data.
638    ///
639    /// Returns a vector of `OwaspCoverageEntry` items, one per OWASP category,
640    /// indicating whether it was tested, whether all related checks passed,
641    /// and which conformance categories contributed to the coverage.
642    pub fn owasp_coverage_data(&self) -> Vec<OwaspCoverageEntry> {
643        // Build a map of feature check_name -> passed/failed status
644        let mut feature_status: HashMap<&str, bool> = HashMap::new(); // true = all passed
645        for feature in ConformanceFeature::all() {
646            let check_name = feature.check_name();
647
648            // Exact match (reference mode)
649            if let Some((passes, fails)) = self.check_results.get(check_name) {
650                let passed = *fails == 0 && *passes > 0;
651                feature_status
652                    .entry(check_name)
653                    .and_modify(|prev| *prev = *prev && passed)
654                    .or_insert(passed);
655            } else {
656                // Prefix match (path-qualified check names)
657                let prefix = format!("{}:", check_name);
658                for (name, (passes, fails)) in &self.check_results {
659                    if name.starts_with(&prefix) {
660                        let passed = *fails == 0 && *passes > 0;
661                        feature_status
662                            .entry(check_name)
663                            .and_modify(|prev| *prev = *prev && passed)
664                            .or_insert(passed);
665                    }
666                }
667            }
668        }
669
670        let mut entries = Vec::new();
671        for category in OwaspCategory::all() {
672            let id = category.identifier();
673            let name = category.short_name();
674
675            let mut tested = false;
676            let mut all_passed = true;
677            let mut via_categories: HashSet<&str> = HashSet::new();
678
679            for feature in ConformanceFeature::all() {
680                if !feature.related_owasp().contains(&id) {
681                    continue;
682                }
683                if let Some(&passed) = feature_status.get(feature.check_name()) {
684                    tested = true;
685                    if !passed {
686                        all_passed = false;
687                    }
688                    via_categories.insert(feature.category());
689                }
690            }
691
692            let mut cats: Vec<String> = via_categories.into_iter().map(String::from).collect();
693            cats.sort();
694
695            entries.push(OwaspCoverageEntry {
696                id: id.to_string(),
697                name: name.to_string(),
698                tested,
699                all_passed: tested && all_passed,
700                via_categories: cats,
701            });
702        }
703
704        entries
705    }
706
707    /// Print OWASP API Security Top 10 coverage based on tested features
708    fn print_owasp_coverage(&self) {
709        println!();
710        println!("{}", "OWASP API Security Top 10 Coverage".bold());
711        println!("{}", "=".repeat(64).bright_green());
712
713        for entry in self.owasp_coverage_data() {
714            let (status, via) = if !entry.tested {
715                ("-".bright_black(), String::new())
716            } else {
717                let via_str = format!(" (via {})", entry.via_categories.join(", "));
718                if entry.all_passed {
719                    ("✓".green(), via_str)
720                } else {
721                    ("⚠".yellow(), format!("{} — has failures", via_str))
722                }
723            };
724
725            println!("  {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
726        }
727    }
728
729    /// Get raw per-check results (for SARIF conversion)
730    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
731        &self.check_results
732    }
733
734    /// Overall pass rate (0.0 - 100.0)
735    pub fn overall_rate(&self) -> f64 {
736        let categories = self.by_category();
737        let total_passed: usize = categories.values().map(|r| r.passed).sum();
738        let total: usize = categories.values().map(|r| r.total()).sum();
739        if total == 0 {
740            0.0
741        } else {
742            (total_passed as f64 / total as f64) * 100.0
743        }
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    #[test]
752    fn test_parse_conformance_report() {
753        let json = r#"{
754            "checks": {
755                "param:path:string": { "passes": 1, "fails": 0 },
756                "param:path:integer": { "passes": 1, "fails": 0 },
757                "body:json": { "passes": 0, "fails": 1 },
758                "method:GET": { "passes": 1, "fails": 0 }
759            },
760            "overall": { "overall_pass_rate": 0.75 }
761        }"#;
762
763        let report = ConformanceReport::from_json(json).unwrap();
764        let categories = report.by_category();
765
766        let params = categories.get("Parameters").unwrap();
767        assert_eq!(params.passed, 2);
768
769        let bodies = categories.get("Request Bodies").unwrap();
770        assert_eq!(bodies.failed, 1);
771    }
772
773    #[test]
774    fn test_empty_report() {
775        let json = r#"{ "checks": {} }"#;
776        let report = ConformanceReport::from_json(json).unwrap();
777        assert_eq!(report.overall_rate(), 0.0);
778    }
779
780    #[test]
781    fn test_owasp_coverage_with_failures() {
782        // response:404 maps to API8 + API9, body:json maps to API4 + API8
783        // response:404 fails, so API8 and API9 should show as having failures
784        // body:json passes, so API4 should show as passing
785        let json = r#"{
786            "checks": {
787                "response:404": { "passes": 0, "fails": 1 },
788                "body:json": { "passes": 1, "fails": 0 },
789                "method:GET": { "passes": 1, "fails": 0 }
790            },
791            "overall": {}
792        }"#;
793
794        let report = ConformanceReport::from_json(json).unwrap();
795        // Print the report to verify visually (--nocapture)
796        report.print_report_with_options(false);
797    }
798}