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