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/// Extract the base name of a custom check.
100/// Custom sub-checks have format "custom:name:header:..." or "custom:name:body:..."
101/// The base name is just the primary check (e.g., "custom:pets-returns-200").
102fn extract_custom_base_name(check_name: &str) -> String {
103    // "custom:" prefix is 7 chars. Find the next colon after that.
104    let after_prefix = &check_name[7..];
105    if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
106        check_name[..7 + pos].to_string()
107    } else {
108        check_name.to_string()
109    }
110}
111
112/// Conformance test report
113pub struct ConformanceReport {
114    /// Per-check results: check_name -> (passes, fails)
115    check_results: HashMap<String, (u64, u64)>,
116    /// Detailed failure information
117    failure_details: Vec<FailureDetail>,
118}
119
120impl ConformanceReport {
121    /// Parse a conformance report from k6's handleSummary JSON output
122    ///
123    /// Also loads failure details from `conformance-failure-details.json` in the same directory.
124    pub fn from_file(path: &Path) -> Result<Self> {
125        let content = std::fs::read_to_string(path)
126            .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
127        let mut report = Self::from_json(&content)?;
128
129        // Load failure details from sibling file
130        if let Some(parent) = path.parent() {
131            let details_path = parent.join("conformance-failure-details.json");
132            if details_path.exists() {
133                if let Ok(details_json) = std::fs::read_to_string(&details_path) {
134                    if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
135                        report.failure_details = details;
136                    }
137                }
138            }
139        }
140
141        Ok(report)
142    }
143
144    /// Parse from JSON string
145    pub fn from_json(json_str: &str) -> Result<Self> {
146        let json: serde_json::Value = serde_json::from_str(json_str)
147            .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
148
149        let mut check_results = HashMap::new();
150
151        if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
152            for (name, result) in checks {
153                let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
154                let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
155                check_results.insert(name.clone(), (passes, fails));
156            }
157        }
158
159        Ok(Self {
160            check_results,
161            failure_details: Vec::new(),
162        })
163    }
164
165    /// Get results grouped by category.
166    ///
167    /// Includes all standard categories plus a synthetic "Custom" category
168    /// for any check names starting with "custom:".
169    pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
170        let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
171
172        // Initialize all categories
173        for cat in ConformanceFeature::categories() {
174            categories.insert(cat, CategoryResult::default());
175        }
176
177        // Map check results to features.
178        // Check names are path-qualified (e.g. "constraint:required:/users")
179        // so we match by prefix as well as exact name.
180        for feature in ConformanceFeature::all() {
181            let check_name = feature.check_name();
182            let category = feature.category();
183
184            let entry = categories.entry(category).or_default();
185
186            // First try exact match (reference mode)
187            if let Some((passes, fails)) = self.check_results.get(check_name) {
188                if *fails == 0 && *passes > 0 {
189                    entry.passed += 1;
190                } else {
191                    entry.failed += 1;
192                }
193            } else {
194                // Try prefix match (path-qualified: "constraint:required:/path")
195                let prefix = format!("{}:", check_name);
196                for (name, (passes, fails)) in &self.check_results {
197                    if name.starts_with(&prefix) {
198                        if *fails == 0 && *passes > 0 {
199                            entry.passed += 1;
200                        } else {
201                            entry.failed += 1;
202                        }
203                    }
204                }
205                // Features not in results are not counted
206            }
207        }
208
209        // Aggregate custom checks (check names starting with "custom:")
210        let custom_entry = categories.entry("Custom").or_default();
211        // Track which top-level custom check names we've already counted
212        let mut counted_custom: HashSet<String> = HashSet::new();
213        for (name, (passes, fails)) in &self.check_results {
214            if name.starts_with("custom:") {
215                // Only count the primary check (status), not sub-checks (header/body)
216                // Sub-checks have format "custom:name:header:..." or "custom:name:body:..."
217                // Primary checks are just "custom:something" with exactly one colon after "custom"
218                // We count each unique top-level custom check once
219                let base_name = extract_custom_base_name(name);
220                if counted_custom.insert(base_name) {
221                    if *fails == 0 && *passes > 0 {
222                        custom_entry.passed += 1;
223                    } else {
224                        custom_entry.failed += 1;
225                    }
226                }
227            }
228        }
229
230        categories
231    }
232
233    /// Print the conformance report to stdout
234    pub fn print_report(&self) {
235        self.print_report_with_options(false);
236    }
237
238    /// Print the conformance report with options controlling detail level
239    pub fn print_report_with_options(&self, all_operations: bool) {
240        let categories = self.by_category();
241
242        // Count detected features and active categories
243        let total_possible = ConformanceFeature::all().len();
244        let active_cats: usize = ConformanceFeature::categories()
245            .iter()
246            .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
247            .count();
248        let detected: usize =
249            categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
250
251        println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
252        println!("{}", "=".repeat(64).bright_green());
253
254        println!(
255            "{}",
256            format!(
257                "Spec Analysis: {} of {} features detected across {} categories",
258                detected, total_possible, active_cats
259            )
260            .bright_cyan()
261        );
262        println!();
263
264        println!(
265            "{:<20} {:>8} {:>8} {:>8} {:>8}",
266            "Category".bold(),
267            "Passed".green().bold(),
268            "Failed".red().bold(),
269            "Total".bold(),
270            "Rate".bold()
271        );
272        println!("{}", "-".repeat(64));
273
274        let mut total_passed = 0usize;
275        let mut total_failed = 0usize;
276        let mut empty_categories: Vec<&str> = Vec::new();
277
278        // Build the list of categories to display (standard + Custom if present)
279        let all_cat_names: Vec<&str> = {
280            let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
281            if categories.get("Custom").is_some_and(|r| r.total() > 0) {
282                cats.push("Custom");
283            }
284            cats
285        };
286
287        for cat_name in &all_cat_names {
288            if let Some(result) = categories.get(cat_name) {
289                let total = result.total();
290                if total == 0 {
291                    // Show empty categories with dimmed "not in spec" indicator
292                    println!(
293                        "{:<20} {:>8} {:>8} {:>8} {:>8}",
294                        cat_name.bright_black(),
295                        "-".bright_black(),
296                        "-".bright_black(),
297                        "-".bright_black(),
298                        "not in spec".bright_black()
299                    );
300                    empty_categories.push(cat_name);
301                    continue;
302                }
303                total_passed += result.passed;
304                total_failed += result.failed;
305
306                let rate_str = format!("{:.0}%", result.rate());
307                let rate_colored = if result.rate() >= 100.0 {
308                    rate_str.green()
309                } else if result.rate() >= 80.0 {
310                    rate_str.yellow()
311                } else {
312                    rate_str.red()
313                };
314
315                println!(
316                    "{:<20} {:>8} {:>8} {:>8} {:>8}",
317                    cat_name,
318                    result.passed.to_string().green(),
319                    result.failed.to_string().red(),
320                    total,
321                    rate_colored
322                );
323            }
324        }
325
326        println!("{}", "=".repeat(64).bright_green());
327
328        let grand_total = total_passed + total_failed;
329        let overall_rate = if grand_total > 0 {
330            (total_passed as f64 / grand_total as f64) * 100.0
331        } else {
332            0.0
333        };
334        let rate_str = format!("{:.0}%", overall_rate);
335        let rate_colored = if overall_rate >= 100.0 {
336            rate_str.green()
337        } else if overall_rate >= 80.0 {
338            rate_str.yellow()
339        } else {
340            rate_str.red()
341        };
342
343        println!(
344            "{:<20} {:>8} {:>8} {:>8} {:>8}",
345            "Total:".bold(),
346            total_passed.to_string().green(),
347            total_failed.to_string().red(),
348            grand_total,
349            rate_colored
350        );
351
352        // Print failed checks detail section
353        let failed_checks: Vec<_> =
354            self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
355
356        if !failed_checks.is_empty() {
357            println!();
358            println!("{}", "Failed Checks:".red().bold());
359            let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
360            sorted_failures.sort_by_key(|(name, _)| (*name).clone());
361            for (name, (passes, fails)) in sorted_failures {
362                println!(
363                    "  {} ({} passed, {} failed)",
364                    name.red(),
365                    passes.to_string().green(),
366                    fails.to_string().red()
367                );
368
369                // Show failure details if available
370                for detail in &self.failure_details {
371                    if detail.check == *name {
372                        println!(
373                            "    {} {} {}",
374                            "→".bright_black(),
375                            detail.request.method.yellow(),
376                            detail.request.url.bright_black()
377                        );
378                        println!(
379                            "      Expected: {}  Actual status: {}",
380                            detail.expected.yellow(),
381                            detail.response.status.to_string().red()
382                        );
383                        if !detail.response.body.is_empty() {
384                            let body_preview = if detail.response.body.len() > 200 {
385                                format!("{}...", &detail.response.body[..200])
386                            } else {
387                                detail.response.body.clone()
388                            };
389                            println!("      Response body: {}", body_preview.bright_black());
390                        }
391                    }
392                }
393            }
394
395            if !all_operations {
396                println!();
397                println!(
398                    "{}",
399                    "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
400                        .yellow()
401                );
402            }
403
404            if !self.failure_details.is_empty() {
405                println!();
406                println!(
407                    "{}",
408                    "Full failure details saved to conformance-report.json (see failure_details array)."
409                        .bright_black()
410                );
411            }
412        }
413
414        // OWASP API Top 10 coverage section
415        self.print_owasp_coverage();
416
417        // Coverage tips for empty categories
418        if !empty_categories.is_empty() {
419            println!();
420            println!("{}", "Coverage Tips".bold());
421            println!("{}", "-".repeat(64));
422            for cat in &empty_categories {
423                if *cat == "Custom" {
424                    continue;
425                }
426                println!(
427                    "  {} {}: {}",
428                    "->".bright_cyan(),
429                    cat,
430                    ConformanceFeature::category_hint(cat).bright_black()
431                );
432            }
433            println!();
434            println!(
435                "{}",
436                "Use --conformance-custom <file.yaml> to add custom checks for any category."
437                    .bright_black()
438            );
439        }
440
441        println!();
442    }
443
444    /// Print OWASP API Security Top 10 coverage based on tested features
445    fn print_owasp_coverage(&self) {
446        println!();
447        println!("{}", "OWASP API Security Top 10 Coverage".bold());
448        println!("{}", "=".repeat(64).bright_green());
449
450        // Build a map of feature check_name → passed/failed status
451        let mut feature_status: HashMap<&str, bool> = HashMap::new(); // true = all passed
452        for feature in ConformanceFeature::all() {
453            let check_name = feature.check_name();
454
455            // Exact match (reference mode)
456            if let Some((passes, fails)) = self.check_results.get(check_name) {
457                let passed = *fails == 0 && *passes > 0;
458                feature_status
459                    .entry(check_name)
460                    .and_modify(|prev| *prev = *prev && passed)
461                    .or_insert(passed);
462            } else {
463                // Prefix match (path-qualified check names)
464                let prefix = format!("{}:", check_name);
465                for (name, (passes, fails)) in &self.check_results {
466                    if name.starts_with(&prefix) {
467                        let passed = *fails == 0 && *passes > 0;
468                        feature_status
469                            .entry(check_name)
470                            .and_modify(|prev| *prev = *prev && passed)
471                            .or_insert(passed);
472                    }
473                }
474            }
475        }
476
477        for category in OwaspCategory::all() {
478            let id = category.identifier();
479            let name = category.short_name();
480
481            // Find features that map to this OWASP category and were tested
482            let mut tested = false;
483            let mut all_passed = true;
484            let mut via_categories: HashSet<&str> = HashSet::new();
485
486            for feature in ConformanceFeature::all() {
487                if !feature.related_owasp().contains(&id) {
488                    continue;
489                }
490                if let Some(&passed) = feature_status.get(feature.check_name()) {
491                    tested = true;
492                    if !passed {
493                        all_passed = false;
494                    }
495                    via_categories.insert(feature.category());
496                }
497            }
498
499            let (status, via) = if !tested {
500                ("-".bright_black(), String::new())
501            } else {
502                let mut cats: Vec<&str> = via_categories.into_iter().collect();
503                cats.sort();
504                let via_str = format!(" (via {})", cats.join(", "));
505                if all_passed {
506                    ("✓".green(), via_str)
507                } else {
508                    ("⚠".yellow(), format!("{} — has failures", via_str))
509                }
510            };
511
512            println!("  {:<12} {:<40} {}{}", id, name, status, via);
513        }
514    }
515
516    /// Get raw per-check results (for SARIF conversion)
517    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
518        &self.check_results
519    }
520
521    /// Overall pass rate (0.0 - 100.0)
522    pub fn overall_rate(&self) -> f64 {
523        let categories = self.by_category();
524        let total_passed: usize = categories.values().map(|r| r.passed).sum();
525        let total: usize = categories.values().map(|r| r.total()).sum();
526        if total == 0 {
527            0.0
528        } else {
529            (total_passed as f64 / total as f64) * 100.0
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn test_parse_conformance_report() {
540        let json = r#"{
541            "checks": {
542                "param:path:string": { "passes": 1, "fails": 0 },
543                "param:path:integer": { "passes": 1, "fails": 0 },
544                "body:json": { "passes": 0, "fails": 1 },
545                "method:GET": { "passes": 1, "fails": 0 }
546            },
547            "overall": { "overall_pass_rate": 0.75 }
548        }"#;
549
550        let report = ConformanceReport::from_json(json).unwrap();
551        let categories = report.by_category();
552
553        let params = categories.get("Parameters").unwrap();
554        assert_eq!(params.passed, 2);
555
556        let bodies = categories.get("Request Bodies").unwrap();
557        assert_eq!(bodies.failed, 1);
558    }
559
560    #[test]
561    fn test_empty_report() {
562        let json = r#"{ "checks": {} }"#;
563        let report = ConformanceReport::from_json(json).unwrap();
564        assert_eq!(report.overall_rate(), 0.0);
565    }
566
567    #[test]
568    fn test_owasp_coverage_with_failures() {
569        // response:404 maps to API8 + API9, body:json maps to API4 + API8
570        // response:404 fails, so API8 and API9 should show as having failures
571        // body:json passes, so API4 should show as passing
572        let json = r#"{
573            "checks": {
574                "response:404": { "passes": 0, "fails": 1 },
575                "body:json": { "passes": 1, "fails": 0 },
576                "method:GET": { "passes": 1, "fails": 0 }
577            },
578            "overall": {}
579        }"#;
580
581        let report = ConformanceReport::from_json(json).unwrap();
582        // Print the report to verify visually (--nocapture)
583        report.print_report_with_options(false);
584    }
585}