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").
121#[allow(dead_code)]
122fn extract_custom_base_name(check_name: &str) -> String {
123    // "custom:" prefix is 7 chars. Find the next colon after that.
124    let after_prefix = &check_name[7..];
125    if let Some(pos) = after_prefix.find(":header:").or(after_prefix.find(":body:")) {
126        check_name[..7 + pos].to_string()
127    } else {
128        check_name.to_string()
129    }
130}
131
132/// Round 41 (#79) — `true` when `check_name` is the PRIMARY status
133/// check for a custom YAML entry, not a header / body sub-check.
134/// Sub-checks have the form `custom:name:header:<header>` or
135/// `custom:name:body:<field>:<type>`; we only count primaries in the
136/// "Custom" category total so a check with N header assertions
137/// doesn't appear as N+1 requests.
138fn is_primary_custom_check(check_name: &str) -> bool {
139    if !check_name.starts_with("custom:") {
140        return false;
141    }
142    let after_prefix = &check_name[7..];
143    !after_prefix.contains(":header:") && !after_prefix.contains(":body:")
144}
145
146/// Conformance test report
147pub struct ConformanceReport {
148    /// Per-check results: check_name -> (passes, fails)
149    check_results: HashMap<String, (u64, u64)>,
150    /// Detailed failure information
151    failure_details: Vec<FailureDetail>,
152}
153
154impl ConformanceReport {
155    /// Construct a report directly from check results and failure details.
156    /// Used by `NativeConformanceExecutor` to build a report without k6.
157    pub fn from_results(
158        check_results: HashMap<String, (u64, u64)>,
159        failure_details: Vec<FailureDetail>,
160    ) -> Self {
161        Self {
162            check_results,
163            failure_details,
164        }
165    }
166
167    /// Serialize the report to JSON.
168    ///
169    /// Includes both the raw `checks` map (for CLI/k6 compat) and structured
170    /// `summary`, `categories`, and `failures` fields (for UI consumption).
171    pub fn to_json(&self) -> serde_json::Value {
172        let mut checks = serde_json::Map::new();
173        for (name, (passes, fails)) in &self.check_results {
174            checks.insert(
175                name.clone(),
176                serde_json::json!({
177                    "passes": passes,
178                    "fails": fails,
179                }),
180            );
181        }
182
183        // Compute structured category results for UI
184        let by_cat = self.by_category();
185        let mut categories_json = serde_json::Map::new();
186        for (cat_name, cat_result) in &by_cat {
187            categories_json.insert(
188                (*cat_name).to_string(),
189                serde_json::json!({
190                    "passed": cat_result.passed,
191                    "total": cat_result.total(),
192                    "rate": cat_result.rate(),
193                }),
194            );
195        }
196
197        // Compute summary
198        let total_passed: usize = by_cat.values().map(|r| r.passed).sum();
199        let total: usize = by_cat.values().map(|r| r.total()).sum();
200        let overall_rate = if total == 0 {
201            0.0
202        } else {
203            (total_passed as f64 / total as f64) * 100.0
204        };
205
206        // Transform failure details into UI-friendly format
207        let failures: Vec<serde_json::Value> = self
208            .failure_details
209            .iter()
210            .map(|d| {
211                let category = Self::category_for_check(&d.check);
212                serde_json::json!({
213                    "check_name": d.check,
214                    "category": category,
215                    "expected": d.expected,
216                    "actual": format!("status {}", d.response.status),
217                    "details": format!("{} {}", d.request.method, d.request.url),
218                })
219            })
220            .collect();
221
222        let mut result = serde_json::json!({
223            "checks": checks,
224            "summary": {
225                "total_checks": total,
226                "passed": total_passed,
227                "failed": total - total_passed,
228                "overall_rate": overall_rate,
229            },
230            "categories": categories_json,
231            "failures": failures,
232        });
233
234        // Keep raw failure_details for backward compat
235        if !self.failure_details.is_empty() {
236            result["failure_details"] = serde_json::to_value(&self.failure_details)
237                .unwrap_or(serde_json::Value::Array(Vec::new()));
238        }
239        result
240    }
241
242    /// Determine the category for a check name based on its prefix
243    fn category_for_check(check_name: &str) -> &'static str {
244        let prefix = check_name.split(':').next().unwrap_or("");
245        match prefix {
246            "param" => "Parameters",
247            "body" => "Request Bodies",
248            "response" => "Response Codes",
249            "schema" => "Schema Types",
250            "compose" => "Composition",
251            "format" => "String Formats",
252            "constraint" => "Constraints",
253            "security" => "Security",
254            "method" => "HTTP Methods",
255            "content" => "Content Types",
256            "validation" | "response_validation" => "Response Validation",
257            "custom" => "Custom",
258            _ => "Other",
259        }
260    }
261
262    /// Get the failure details
263    pub fn failure_details(&self) -> &[FailureDetail] {
264        &self.failure_details
265    }
266
267    /// Parse a conformance report from k6's handleSummary JSON output
268    ///
269    /// Also loads failure details from `conformance-failure-details.json` in the same directory.
270    pub fn from_file(path: &Path) -> Result<Self> {
271        let content = std::fs::read_to_string(path)
272            .map_err(|e| BenchError::Other(format!("Failed to read conformance report: {}", e)))?;
273        let mut report = Self::from_json(&content)?;
274
275        // Load failure details from sibling file
276        if let Some(parent) = path.parent() {
277            let details_path = parent.join("conformance-failure-details.json");
278            if details_path.exists() {
279                if let Ok(details_json) = std::fs::read_to_string(&details_path) {
280                    if let Ok(details) = serde_json::from_str::<Vec<FailureDetail>>(&details_json) {
281                        report.failure_details = details;
282                    }
283                }
284            }
285        }
286
287        Ok(report)
288    }
289
290    /// Parse from JSON string
291    pub fn from_json(json_str: &str) -> Result<Self> {
292        let json: serde_json::Value = serde_json::from_str(json_str)
293            .map_err(|e| BenchError::Other(format!("Failed to parse conformance JSON: {}", e)))?;
294
295        let mut check_results = HashMap::new();
296
297        if let Some(checks) = json.get("checks").and_then(|c| c.as_object()) {
298            for (name, result) in checks {
299                let passes = result.get("passes").and_then(|v| v.as_u64()).unwrap_or(0);
300                let fails = result.get("fails").and_then(|v| v.as_u64()).unwrap_or(0);
301                check_results.insert(name.clone(), (passes, fails));
302            }
303        }
304
305        Ok(Self {
306            check_results,
307            failure_details: Vec::new(),
308        })
309    }
310
311    /// Get results grouped by category.
312    ///
313    /// Includes all standard categories plus a synthetic "Custom" category
314    /// for any check names starting with "custom:".
315    pub fn by_category(&self) -> HashMap<&'static str, CategoryResult> {
316        let mut categories: HashMap<&'static str, CategoryResult> = HashMap::new();
317
318        // Initialize all categories
319        for cat in ConformanceFeature::categories() {
320            categories.insert(cat, CategoryResult::default());
321        }
322
323        // Map check results to features.
324        // Check names are path-qualified (e.g. "constraint:required:/users")
325        // so we match by prefix as well as exact name.
326        for feature in ConformanceFeature::all() {
327            let check_name = feature.check_name();
328            let category = feature.category();
329
330            let entry = categories.entry(category).or_default();
331
332            // First try exact match (reference mode)
333            if let Some((passes, fails)) = self.check_results.get(check_name) {
334                if *fails == 0 && *passes > 0 {
335                    entry.passed += 1;
336                } else {
337                    entry.failed += 1;
338                }
339            } else {
340                // Try prefix match (path-qualified: "constraint:required:/path")
341                let prefix = format!("{}:", check_name);
342                for (name, (passes, fails)) in &self.check_results {
343                    if name.starts_with(&prefix) {
344                        if *fails == 0 && *passes > 0 {
345                            entry.passed += 1;
346                        } else {
347                            entry.failed += 1;
348                        }
349                    }
350                }
351                // Features not in results are not counted
352            }
353        }
354
355        // Aggregate custom checks (check names starting with "custom:").
356        //
357        // Round 41 (#79) — Srikanth on 0.3.185: with
358        // `chain_iterations: 3` and a chain of `custom:get` +
359        // `custom:post (repeat: 16 parallel)`, the bench fired 3 + 48
360        // = 51 requests and the per-request log records each one,
361        // but the summary table collapsed the row to `Custom: 2 / 0
362        // / 2 / 100%` because the prior implementation deduped by
363        // top-level check name and added 1 pass/fail per unique
364        // name. Now we sum the per-occurrence counts so the summary
365        // matches what actually went on the wire.
366        //
367        // Only the PRIMARY check (the status assertion that uses the
368        // bare `custom:name`) is added to the category total —
369        // sub-checks like `custom:name:header:X` and
370        // `custom:name:body:Y` are still counted toward total
371        // pass/fail at the report level via `check_results`, but
372        // each sub-check is a property of the SAME request, not an
373        // extra one, so adding them here would double-count requests
374        // that have header / body assertions.
375        let custom_entry = categories.entry("Custom").or_default();
376        for (name, (passes, fails)) in &self.check_results {
377            if name.starts_with("custom:") && is_primary_custom_check(name) {
378                custom_entry.passed += *passes as usize;
379                custom_entry.failed += *fails as usize;
380            }
381        }
382
383        categories
384    }
385
386    /// Print the conformance report to stdout
387    pub fn print_report(&self) {
388        self.print_report_with_options(false);
389    }
390
391    /// Print the conformance report with options controlling detail level
392    pub fn print_report_with_options(&self, all_operations: bool) {
393        let categories = self.by_category();
394
395        // Count detected features and active categories
396        let total_possible = ConformanceFeature::all().len();
397        let active_cats: usize = ConformanceFeature::categories()
398            .iter()
399            .filter(|c| categories.get(*c).is_some_and(|r| r.total() > 0))
400            .count();
401        let detected: usize =
402            categories.iter().filter(|(k, _)| *k != &"Custom").map(|(_, v)| v.total()).sum();
403
404        println!("\n{}", "OpenAPI 3.0.0 Conformance Report".bold());
405        println!("{}", "=".repeat(64).bright_green());
406
407        println!(
408            "{}",
409            format!(
410                "Spec Analysis: {} of {} features detected across {} categories",
411                detected, total_possible, active_cats
412            )
413            .bright_cyan()
414        );
415        println!();
416
417        println!(
418            "{:<20} {:>8} {:>8} {:>8} {:>8}",
419            "Category".bold(),
420            "Passed".green().bold(),
421            "Failed".red().bold(),
422            "Total".bold(),
423            "Rate".bold()
424        );
425        println!("{}", "-".repeat(64));
426
427        let mut total_passed = 0usize;
428        let mut total_failed = 0usize;
429        let mut empty_categories: Vec<&str> = Vec::new();
430
431        // Build the list of categories to display (standard + Custom if present)
432        let all_cat_names: Vec<&str> = {
433            let mut cats: Vec<&str> = ConformanceFeature::categories().to_vec();
434            if categories.get("Custom").is_some_and(|r| r.total() > 0) {
435                cats.push("Custom");
436            }
437            cats
438        };
439
440        for cat_name in &all_cat_names {
441            if let Some(result) = categories.get(cat_name) {
442                let total = result.total();
443                if total == 0 {
444                    // Show empty categories with dimmed "not in spec" indicator
445                    println!(
446                        "{:<20} {:>8} {:>8} {:>8} {:>8}",
447                        cat_name.bright_black(),
448                        "-".bright_black(),
449                        "-".bright_black(),
450                        "-".bright_black(),
451                        "not in spec".bright_black()
452                    );
453                    empty_categories.push(cat_name);
454                    continue;
455                }
456                total_passed += result.passed;
457                total_failed += result.failed;
458
459                let rate_str = format!("{:.0}%", result.rate());
460                let rate_colored = if result.rate() >= 100.0 {
461                    rate_str.green()
462                } else if result.rate() >= 80.0 {
463                    rate_str.yellow()
464                } else {
465                    rate_str.red()
466                };
467
468                println!(
469                    "{:<20} {:>8} {:>8} {:>8} {:>8}",
470                    cat_name,
471                    result.passed.to_string().green(),
472                    result.failed.to_string().red(),
473                    total,
474                    rate_colored
475                );
476            }
477        }
478
479        println!("{}", "=".repeat(64).bright_green());
480
481        let grand_total = total_passed + total_failed;
482        let overall_rate = if grand_total > 0 {
483            (total_passed as f64 / grand_total as f64) * 100.0
484        } else {
485            0.0
486        };
487        let rate_str = format!("{:.0}%", overall_rate);
488        let rate_colored = if overall_rate >= 100.0 {
489            rate_str.green()
490        } else if overall_rate >= 80.0 {
491            rate_str.yellow()
492        } else {
493            rate_str.red()
494        };
495
496        println!(
497            "{:<20} {:>8} {:>8} {:>8} {:>8}",
498            "Total:".bold(),
499            total_passed.to_string().green(),
500            total_failed.to_string().red(),
501            grand_total,
502            rate_colored
503        );
504
505        // Print failed checks detail section
506        let failed_checks: Vec<_> =
507            self.check_results.iter().filter(|(_, (_, fails))| *fails > 0).collect();
508
509        if !failed_checks.is_empty() {
510            println!();
511            println!("{}", "Failed Checks:".red().bold());
512            let mut sorted_failures: Vec<_> = failed_checks.into_iter().collect();
513            sorted_failures.sort_by_key(|(name, _)| (*name).clone());
514            for (name, (passes, fails)) in sorted_failures {
515                println!(
516                    "  {} ({} passed, {} failed)",
517                    name.red(),
518                    passes.to_string().green(),
519                    fails.to_string().red()
520                );
521
522                // Show failure details if available
523                for detail in &self.failure_details {
524                    if detail.check == *name {
525                        println!(
526                            "    {} {} {}",
527                            "→".bright_black(),
528                            detail.request.method.yellow(),
529                            detail.request.url.bright_black()
530                        );
531                        println!(
532                            "      Expected: {}  Actual status: {}",
533                            detail.expected.yellow(),
534                            detail.response.status.to_string().red()
535                        );
536                        if !detail.response.body.is_empty() {
537                            let body_preview = if detail.response.body.len() > 200 {
538                                format!("{}...", &detail.response.body[..200])
539                            } else {
540                                detail.response.body.clone()
541                            };
542                            println!("      Response body: {}", body_preview.bright_black());
543                        }
544                        // Show field-level schema violations if present
545                        if !detail.schema_violations.is_empty() {
546                            println!(
547                                "      {} ({} violation{}):",
548                                "Schema violations".red(),
549                                detail.schema_violations.len(),
550                                if detail.schema_violations.len() == 1 {
551                                    ""
552                                } else {
553                                    "s"
554                                }
555                            );
556                            for violation in &detail.schema_violations {
557                                println!(
558                                    "        {} {}: {} (expected: {}, actual: {})",
559                                    "·".bright_black(),
560                                    violation.field_path.yellow(),
561                                    violation.violation_type.red(),
562                                    violation.expected.bright_black(),
563                                    violation.actual.bright_black()
564                                );
565                            }
566                        }
567                    }
568                }
569            }
570
571            if !all_operations {
572                println!();
573                println!(
574                    "{}",
575                    "Tip: Use --conformance-all-operations (without --conformance-categories) to see which specific endpoints failed across all categories."
576                        .yellow()
577                );
578            }
579
580            if !self.failure_details.is_empty() {
581                println!();
582                println!(
583                    "{}",
584                    "Full failure details saved to conformance-report.json (see failure_details array)."
585                        .bright_black()
586                );
587            }
588
589            // Check for 429 rate limiting in failure details
590            let rate_limited_count =
591                self.failure_details.iter().filter(|d| d.response.status == 429).count();
592            if rate_limited_count > 0 {
593                println!();
594                println!(
595                    "{}",
596                    format!(
597                        "Warning: {} check(s) received HTTP 429 (Too Many Requests) from the target API.",
598                        rate_limited_count
599                    )
600                    .yellow()
601                    .bold()
602                );
603                println!(
604                    "{}",
605                    "The target server is rate-limiting requests. Use --conformance-delay <ms> to add a pause"
606                        .yellow()
607                );
608                println!(
609                    "{}",
610                    "between requests (e.g., --conformance-delay 200 for 200ms between each check)."
611                        .yellow()
612                );
613            }
614
615            // Explain what "Failed" means
616            println!();
617            println!(
618                "{}",
619                "Note: \"Failed\" = the API response did not match the expected conformance check"
620                    .bright_black()
621            );
622            println!(
623                "{}",
624                "(e.g., wrong status code, missing field). These are NOT infrastructure errors."
625                    .bright_black()
626            );
627        }
628
629        // OWASP API Top 10 coverage section
630        self.print_owasp_coverage();
631
632        // Coverage tips for empty categories
633        if !empty_categories.is_empty() {
634            println!();
635            println!("{}", "Coverage Tips".bold());
636            println!("{}", "-".repeat(64));
637            for cat in &empty_categories {
638                if *cat == "Custom" {
639                    continue;
640                }
641                println!(
642                    "  {} {}: {}",
643                    "->".bright_cyan(),
644                    cat,
645                    ConformanceFeature::category_hint(cat).bright_black()
646                );
647            }
648            println!();
649            println!(
650                "{}",
651                "Use --conformance-custom <file.yaml> to add custom checks for any category."
652                    .bright_black()
653            );
654        }
655
656        println!();
657    }
658
659    /// Compute OWASP API Security Top 10 coverage as structured data.
660    ///
661    /// Returns a vector of `OwaspCoverageEntry` items, one per OWASP category,
662    /// indicating whether it was tested, whether all related checks passed,
663    /// and which conformance categories contributed to the coverage.
664    pub fn owasp_coverage_data(&self) -> Vec<OwaspCoverageEntry> {
665        // Build a map of feature check_name -> passed/failed status
666        let mut feature_status: HashMap<&str, bool> = HashMap::new(); // true = all passed
667        for feature in ConformanceFeature::all() {
668            let check_name = feature.check_name();
669
670            // Exact match (reference mode)
671            if let Some((passes, fails)) = self.check_results.get(check_name) {
672                let passed = *fails == 0 && *passes > 0;
673                feature_status
674                    .entry(check_name)
675                    .and_modify(|prev| *prev = *prev && passed)
676                    .or_insert(passed);
677            } else {
678                // Prefix match (path-qualified check names)
679                let prefix = format!("{}:", check_name);
680                for (name, (passes, fails)) in &self.check_results {
681                    if name.starts_with(&prefix) {
682                        let passed = *fails == 0 && *passes > 0;
683                        feature_status
684                            .entry(check_name)
685                            .and_modify(|prev| *prev = *prev && passed)
686                            .or_insert(passed);
687                    }
688                }
689            }
690        }
691
692        let mut entries = Vec::new();
693        for category in OwaspCategory::all() {
694            let id = category.identifier();
695            let name = category.short_name();
696
697            let mut tested = false;
698            let mut all_passed = true;
699            let mut via_categories: HashSet<&str> = HashSet::new();
700
701            for feature in ConformanceFeature::all() {
702                if !feature.related_owasp().contains(&id) {
703                    continue;
704                }
705                if let Some(&passed) = feature_status.get(feature.check_name()) {
706                    tested = true;
707                    if !passed {
708                        all_passed = false;
709                    }
710                    via_categories.insert(feature.category());
711                }
712            }
713
714            let mut cats: Vec<String> = via_categories.into_iter().map(String::from).collect();
715            cats.sort();
716
717            entries.push(OwaspCoverageEntry {
718                id: id.to_string(),
719                name: name.to_string(),
720                tested,
721                all_passed: tested && all_passed,
722                via_categories: cats,
723            });
724        }
725
726        entries
727    }
728
729    /// Print OWASP API Security Top 10 coverage based on tested features
730    fn print_owasp_coverage(&self) {
731        println!();
732        println!("{}", "OWASP API Security Top 10 Coverage".bold());
733        println!("{}", "=".repeat(64).bright_green());
734
735        let entries = self.owasp_coverage_data();
736        for entry in &entries {
737            let (status, via) = if !entry.tested {
738                ("-".bright_black(), String::new())
739            } else {
740                let via_str = format!(" (via {})", entry.via_categories.join(", "));
741                if entry.all_passed {
742                    ("✓".green(), via_str)
743                } else {
744                    ("⚠".yellow(), format!("{} — has failures", via_str))
745                }
746            };
747
748            println!("  {:<12} {:<40} {}{}", entry.id, entry.name, status, via);
749        }
750
751        // Round 18.4 — Srikanth's confusion: he saw 5 OWASP rows
752        // show "-" after `--conformance-categories
753        // "security,request-bodies,parameters"` and read it as "not
754        // working". Actually it was working perfectly — his selected
755        // categories simply don't map to the 5 untouched OWASP
756        // categories. Print a footer that explains which conformance
757        // category to add to exercise each untested OWASP category.
758        let untested: Vec<&OwaspCoverageEntry> = entries.iter().filter(|e| !e.tested).collect();
759        if !untested.is_empty() {
760            println!();
761            println!(
762                "{}",
763                "  Untested OWASP categories — add the listed --conformance-categories to exercise:".bright_black()
764            );
765            for entry in untested {
766                let suggestion = suggest_conformance_category_for_owasp(&entry.id);
767                println!(
768                    "    {} {:<40} {}",
769                    entry.id.bright_black(),
770                    entry.name.bright_black(),
771                    suggestion.bright_black()
772                );
773            }
774        }
775    }
776
777    /// Get raw per-check results (for SARIF conversion)
778    pub fn raw_check_results(&self) -> &HashMap<String, (u64, u64)> {
779        &self.check_results
780    }
781
782    /// Overall pass rate (0.0 - 100.0)
783    pub fn overall_rate(&self) -> f64 {
784        let categories = self.by_category();
785        let total_passed: usize = categories.values().map(|r| r.passed).sum();
786        let total: usize = categories.values().map(|r| r.total()).sum();
787        if total == 0 {
788            0.0
789        } else {
790            (total_passed as f64 / total as f64) * 100.0
791        }
792    }
793}
794
795/// Round 18.4 — pair each OWASP category with the conformance
796/// category that exercises it. Used by the report footer when a
797/// category is untested to tell the user *how* to test it.
798fn suggest_conformance_category_for_owasp(owasp_id: &str) -> &'static str {
799    match owasp_id {
800        "API1:2023" => "add `parameters` (path-param probes)",
801        "API2:2023" => "add `security` (auth probes)",
802        "API3:2023" => "add `constraints` (required/property checks)",
803        "API4:2023" => "add `request-bodies` or `constraints` (min/max/pattern/enum)",
804        "API5:2023" => "add `http-methods` (method-by-method coverage)",
805        "API6:2023" => "no single category — requires custom scenario flows",
806        "API7:2023" => "no built-in coverage — requires URL-injection custom checks",
807        "API8:2023" => "add any of `parameters` / `request-bodies` / `schema-types` / `string-formats` / `composition` / `response-codes` / `content-types` / `response-validation`",
808        "API9:2023" => "add `response-codes` or `http-methods`",
809        "API10:2023" => "add `response-validation`",
810        _ => "no mapping",
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    /// Round 41 (#79) — Srikanth on 0.3.185: with `chain_iterations:
819    /// 3` and a chain of `custom:get` + `custom:post` (repeat 16
820    /// parallel), the bench logs 51 requests but the summary table
821    /// previously printed `Custom: 2 / 0 / 2 / 100%` because each
822    /// unique top-level check name was added with `+1` instead of
823    /// `+N`. Now the row sums the per-check `(passes, fails)`
824    /// counts.
825    #[test]
826    fn custom_category_sums_per_check_executions_not_unique_names() {
827        let mut check_results = HashMap::new();
828        // 3 iters x 1 GET = 3
829        check_results.insert("custom:get".to_string(), (3u64, 0u64));
830        // 3 iters x 16 parallel POSTs = 48
831        check_results.insert("custom:post".to_string(), (48u64, 0u64));
832        // Plus a sub-check (header assertion) that should NOT inflate
833        // the request count: same request just has an extra
834        // assertion.
835        check_results.insert("custom:get:header:X-Trace".to_string(), (3u64, 0u64));
836        let report = ConformanceReport::from_results(check_results, Vec::new());
837        let by_cat = report.by_category();
838        let custom = by_cat.get("Custom").expect("Custom category present");
839        assert_eq!(custom.passed, 51, "primary check passes summed (3 + 48)");
840        assert_eq!(custom.failed, 0);
841    }
842
843    /// Round 41 — primary-vs-sub-check classifier.
844    #[test]
845    fn primary_custom_check_classifier_skips_header_and_body_sub_checks() {
846        assert!(is_primary_custom_check("custom:get"));
847        assert!(is_primary_custom_check("custom:do-work-parallel"));
848        assert!(!is_primary_custom_check("custom:get:header:X-Trace"));
849        assert!(!is_primary_custom_check("custom:get:body:user_id:string"));
850        assert!(!is_primary_custom_check("not:custom"));
851    }
852
853    /// Round 18.4 — every OWASP category that the suggestion helper
854    /// is asked about should return a non-empty hint, so the footer
855    /// in the coverage table never prints an empty cell.
856    #[test]
857    fn suggest_conformance_category_returns_a_hint_for_every_owasp() {
858        for category in OwaspCategory::all() {
859            let suggestion = suggest_conformance_category_for_owasp(category.identifier());
860            assert!(
861                !suggestion.is_empty() && suggestion != "no mapping",
862                "no suggestion for {} ({})",
863                category.identifier(),
864                category.short_name()
865            );
866        }
867    }
868
869    #[test]
870    fn test_parse_conformance_report() {
871        let json = r#"{
872            "checks": {
873                "param:path:string": { "passes": 1, "fails": 0 },
874                "param:path:integer": { "passes": 1, "fails": 0 },
875                "body:json": { "passes": 0, "fails": 1 },
876                "method:GET": { "passes": 1, "fails": 0 }
877            },
878            "overall": { "overall_pass_rate": 0.75 }
879        }"#;
880
881        let report = ConformanceReport::from_json(json).unwrap();
882        let categories = report.by_category();
883
884        let params = categories.get("Parameters").unwrap();
885        assert_eq!(params.passed, 2);
886
887        let bodies = categories.get("Request Bodies").unwrap();
888        assert_eq!(bodies.failed, 1);
889    }
890
891    #[test]
892    fn test_empty_report() {
893        let json = r#"{ "checks": {} }"#;
894        let report = ConformanceReport::from_json(json).unwrap();
895        assert_eq!(report.overall_rate(), 0.0);
896    }
897
898    #[test]
899    fn test_owasp_coverage_with_failures() {
900        // response:404 maps to API8 + API9, body:json maps to API4 + API8
901        // response:404 fails, so API8 and API9 should show as having failures
902        // body:json passes, so API4 should show as passing
903        let json = r#"{
904            "checks": {
905                "response:404": { "passes": 0, "fails": 1 },
906                "body:json": { "passes": 1, "fails": 0 },
907                "method:GET": { "passes": 1, "fails": 0 }
908            },
909            "overall": {}
910        }"#;
911
912        let report = ConformanceReport::from_json(json).unwrap();
913        // Print the report to verify visually (--nocapture)
914        report.print_report_with_options(false);
915    }
916}