mockforge-bench 0.3.163

Load and performance testing for MockForge
Documentation
//! HTML report renderer for the conformance self-test.
//!
//! Issue #79 round 17.6 — Srikanth's (17.6) ask: a human-readable
//! report grouped by category, severity, and OWASP class. The JSON
//! reports from rounds 17.x are precise but hard to skim under a
//! deadline; this module renders a self-contained HTML file that
//! drops into a browser without any external assets.
//!
//! Sections:
//! 1. Header: target URL, timestamp, headline counts.
//! 2. Self-test summary cards: positives, negatives caught / missed
//!    per category.
//! 3. Negative detail table (rolled up by category + label) so a
//!    user can drill from "owasp had 12 misses" → which routes.
//! 4. Optional spec-audit section (if a round-17.4 audit JSON is
//!    passed in alongside).
//!
//! Output is one self-contained HTML string: inline CSS, no external
//! fonts or scripts, safe to email or commit to a CI artefact bucket.

use super::self_test::{CaseOutcome, OperationResult, SelfTestReport};
use std::collections::BTreeMap;

/// Render a complete HTML report for the given self-test report.
/// `audit` is an optional `SpecAuditReport`-shaped JSON value — when
/// present, an audit section is appended. We accept `&serde_json::Value`
/// rather than the strongly-typed `SpecAuditReport` to keep this
/// module decoupled from `spec_audit` (which lives on a separate
/// in-flight branch).
pub fn render_html(report: &SelfTestReport, audit: Option<&serde_json::Value>) -> String {
    let mut html = String::new();
    html.push_str(HEAD);
    push_header(&mut html, report);
    push_summary_cards(&mut html, report);
    push_category_table(&mut html, report);
    push_operations_table(&mut html, report);
    if let Some(a) = audit {
        push_spec_audit(&mut html, a);
    }
    html.push_str(FOOT);
    html
}

/// Inline-CSS opening — no external assets, prints fine.
const HEAD: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MockForge Conformance Report</title>
<style>
  body { font-family: -apple-system, system-ui, sans-serif; max-width: 1100px;
         margin: 2rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.5; }
  h1 { font-size: 1.8rem; margin: 0 0 0.5rem; }
  h2 { font-size: 1.3rem; margin: 2rem 0 0.5rem; border-bottom: 1px solid #d1d5db; padding-bottom: 0.3rem; }
  .meta { color: #6b7280; font-size: 0.9rem; }
  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin: 1rem 0; }
  .card { padding: 0.75rem 1rem; border-radius: 6px; background: #f3f4f6; }
  .card .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
  .card .value { font-size: 1.6rem; font-weight: 600; color: #1f2933; }
  .card.ok { background: #ecfdf5; } .card.ok .value { color: #047857; }
  .card.warn { background: #fffbeb; } .card.warn .value { color: #b45309; }
  .card.err { background: #fef2f2; } .card.err .value { color: #b91c1c; }
  table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1.5rem; font-size: 0.9rem; }
  th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
  th { background: #f9fafb; font-weight: 600; color: #374151; }
  tr:hover { background: #f9fafb; }
  .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
  .badge.pass { background: #d1fae5; color: #047857; }
  .badge.fail { background: #fee2e2; color: #b91c1c; }
  .badge.info { background: #dbeafe; color: #1d4ed8; }
  .badge.warn { background: #fef3c7; color: #92400e; }
  .badge.err  { background: #fee2e2; color: #b91c1c; }
  .small { color: #6b7280; font-size: 0.85rem; }
  code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
</style>
</head>
<body>
"#;

const FOOT: &str = "\n</body>\n</html>\n";

fn push_header(out: &mut String, _report: &SelfTestReport) {
    out.push_str("<h1>MockForge Conformance Report</h1>\n");
    out.push_str(
        "<p class=\"meta\">Generated by <code>mockforge bench --conformance-self-test</code></p>\n",
    );
}

fn push_summary_cards(out: &mut String, report: &SelfTestReport) {
    let positives = report.positive_pass + report.positive_fail;
    let neg_caught: usize = report.negative_caught.values().sum();
    let neg_missed: usize = report.negative_missed.values().sum();
    let pos_class = if report.positive_fail == 0 {
        "ok"
    } else {
        "err"
    };
    let miss_class = if neg_missed == 0 { "ok" } else { "warn" };
    out.push_str("<div class=\"cards\">\n");
    push_card(out, "Positive cases", positives, pos_class);
    push_card(out, "Positive failures", report.positive_fail, pos_class);
    push_card(out, "Negatives caught", neg_caught, "ok");
    push_card(out, "Negatives missed", neg_missed, miss_class);
    push_card(out, "Operations", report.operations.len(), "");
    out.push_str("</div>\n");
}

fn push_card(out: &mut String, label: &str, value: usize, class: &str) {
    let class_attr = if class.is_empty() {
        String::new()
    } else {
        format!(" {}", class)
    };
    out.push_str(&format!(
        "  <div class=\"card{class_attr}\"><div class=\"label\">{}</div><div class=\"value\">{}</div></div>\n",
        html_escape(label),
        value
    ));
}

fn push_category_table(out: &mut String, report: &SelfTestReport) {
    out.push_str("<h2>Negatives by category</h2>\n");
    let mut keys: Vec<&String> =
        report.negative_caught.keys().chain(report.negative_missed.keys()).collect();
    keys.sort();
    keys.dedup();
    if keys.is_empty() {
        out.push_str("<p class=\"small\">No negative probes ran — typically means no operations had any injectable surface.</p>\n");
        return;
    }
    out.push_str("<table>\n<thead><tr><th>Category</th><th>Caught (4xx)</th><th>Missed (non-4xx)</th><th>Status</th></tr></thead>\n<tbody>\n");
    for cat in keys {
        let caught = report.negative_caught.get(cat).copied().unwrap_or(0);
        let missed = report.negative_missed.get(cat).copied().unwrap_or(0);
        let (badge_class, badge_text) = if missed == 0 {
            ("pass", "all caught")
        } else {
            ("fail", "gaps")
        };
        out.push_str(&format!(
            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td></tr>\n",
            html_escape(cat),
            caught,
            missed,
            badge_class,
            badge_text
        ));
    }
    out.push_str("</tbody></table>\n");
}

fn push_operations_table(out: &mut String, report: &SelfTestReport) {
    out.push_str("<h2>Per-operation results</h2>\n");
    if report.operations.is_empty() {
        out.push_str("<p class=\"small\">No operations.</p>\n");
        return;
    }
    out.push_str("<table>\n<thead><tr><th>Method</th><th>Path</th><th>Positive</th><th>Negatives caught / missed</th></tr></thead>\n<tbody>\n");
    for op in &report.operations {
        let pos_badge = match &op.positive {
            Some(p) if p.passed => "<span class=\"badge pass\">2xx ✓</span>".to_string(),
            Some(p) => format!("<span class=\"badge fail\">{} ✗</span>", p.actual_status),
            None => "<span class=\"badge info\">none</span>".into(),
        };
        let (caught, missed) = op.negatives.iter().partition::<Vec<&CaseOutcome>, _>(|n| n.passed);
        out.push_str(&format!(
            "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td><td>{} caught / {} missed</td></tr>\n",
            html_escape(&op.method),
            html_escape(&op.path),
            pos_badge,
            caught.len(),
            missed.len()
        ));
    }
    out.push_str("</tbody></table>\n");
    push_missed_detail(out, report);
}

fn push_missed_detail(out: &mut String, report: &SelfTestReport) {
    // List every individual missed negative for drill-down. Capped at
    // 200 rows to keep the HTML file under a reasonable size on huge
    // specs — the JSON report has the full set if more is needed.
    let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
    for op in &report.operations {
        for neg in &op.negatives {
            if !neg.passed {
                missed.push((op, neg));
            }
        }
    }
    if missed.is_empty() {
        return;
    }
    out.push_str("<h2>Missed negatives (validator gaps)</h2>\n");
    out.push_str(&format!(
        "<p class=\"small\">{} missed negative(s). Showing up to 200 — full set in <code>conformance-self-test.json</code>.</p>\n",
        missed.len()
    ));
    out.push_str("<table>\n<thead><tr><th>Method</th><th>Path</th><th>Label</th><th>Actual</th></tr></thead>\n<tbody>\n");
    for (op, neg) in missed.iter().take(200) {
        out.push_str(&format!(
            "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>\n",
            html_escape(&op.method),
            html_escape(&op.path),
            html_escape(&neg.label),
            neg.actual_status
        ));
    }
    out.push_str("</tbody></table>\n");
}

fn push_spec_audit(out: &mut String, audit: &serde_json::Value) {
    out.push_str("<h2>Spec audit</h2>\n");
    let findings = audit.get("findings").and_then(|v| v.as_array());
    let coverage = audit.get("datatype_coverage").and_then(|v| v.as_object());
    let ops = audit.get("operations_audited").and_then(|v| v.as_u64()).unwrap_or(0);
    out.push_str(&format!(
        "<p class=\"small\">Audited {ops} operation(s). Coverage map: {} datatype kind(s).</p>\n",
        coverage.map(|c| c.len()).unwrap_or(0)
    ));
    if let Some(findings) = findings {
        if findings.is_empty() {
            out.push_str("<p class=\"small\">No findings.</p>\n");
        } else {
            // Group findings by severity for an easy scan.
            let mut by_sev: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
            for f in findings {
                let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
                by_sev.entry(sev).or_default().push(f);
            }
            out.push_str("<table>\n<thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Message</th></tr></thead>\n<tbody>\n");
            for (sev, items) in by_sev {
                let badge_class = match sev.as_str() {
                    "error" => "err",
                    "warning" => "warn",
                    _ => "info",
                };
                for item in items {
                    let cat = item.get("category").and_then(|v| v.as_str()).unwrap_or("");
                    let loc = item.get("location").and_then(|v| v.as_str()).unwrap_or("");
                    let msg = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
                    out.push_str(&format!(
                        "<tr><td><span class=\"badge {}\">{}</span></td><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>\n",
                        badge_class,
                        html_escape(&sev),
                        html_escape(cat),
                        html_escape(loc),
                        html_escape(msg)
                    ));
                }
            }
            out.push_str("</tbody></table>\n");
        }
    }
    if let Some(coverage) = coverage {
        let mut entries: Vec<(&String, u64)> =
            coverage.iter().filter_map(|(k, v)| v.as_u64().map(|c| (k, c))).collect();
        entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
        if !entries.is_empty() {
            out.push_str("<h2>Datatype coverage</h2>\n");
            out.push_str("<table>\n<thead><tr><th>Type</th><th>Count</th></tr></thead>\n<tbody>\n");
            for (kind, count) in entries.iter().take(40) {
                out.push_str(&format!(
                    "<tr><td><code>{}</code></td><td>{}</td></tr>\n",
                    html_escape(kind),
                    count
                ));
            }
            out.push_str("</tbody></table>\n");
        }
    }
}

fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::conformance::self_test::{CaseOutcome, OperationResult, SelfTestReport};

    fn sample_report() -> SelfTestReport {
        SelfTestReport {
            positive_pass: 3,
            positive_fail: 1,
            negative_caught: BTreeMap::from([("request-body".into(), 4), ("parameters".into(), 2)]),
            negative_missed: BTreeMap::from([("owasp".into(), 1)]),
            operations: vec![OperationResult {
                method: "POST".into(),
                path: "/users".into(),
                positive: Some(CaseOutcome {
                    label: "positive".into(),
                    expected_4xx: false,
                    actual_status: 201,
                    passed: true,
                }),
                negatives: vec![CaseOutcome {
                    label: "owasp:sqli".into(),
                    expected_4xx: true,
                    actual_status: 200,
                    passed: false,
                }],
            }],
        }
    }

    #[test]
    fn html_contains_expected_sections() {
        let html = render_html(&sample_report(), None);
        assert!(html.contains("<title>MockForge Conformance Report</title>"));
        assert!(html.contains("Positive cases"));
        assert!(html.contains("Negatives by category"));
        assert!(html.contains("Per-operation results"));
        assert!(html.contains("Missed negatives"));
        // Specific data points from the sample report:
        assert!(html.contains("request-body"));
        assert!(html.contains("owasp:sqli"));
        assert!(html.contains("/users"));
    }

    #[test]
    fn html_renders_audit_section_when_present() {
        let audit = serde_json::json!({
            "findings": [
                {"category": "servers", "severity": "warning",
                 "location": "#/servers", "message": "no servers declared"}
            ],
            "datatype_coverage": {"string": 5, "integer": 3},
            "operations_audited": 7
        });
        let html = render_html(&sample_report(), Some(&audit));
        assert!(html.contains("Spec audit"));
        assert!(html.contains("no servers declared"));
        assert!(html.contains("Datatype coverage"));
        assert!(html.contains("string"));
        assert!(html.contains("Audited 7 operation"));
    }

    #[test]
    fn html_escapes_special_chars_in_labels() {
        let mut report = sample_report();
        report.operations[0].path = "/items/<script>".into();
        report.operations[0].negatives[0].label = "owasp:xss:<>\"&".into();
        let html = render_html(&report, None);
        // The literal special chars should be escaped, not rendered raw.
        assert!(!html.contains("/items/<script>"));
        assert!(html.contains("&lt;script&gt;"));
        assert!(html.contains("&quot;"));
    }

    #[test]
    fn html_handles_empty_report() {
        let html = render_html(&SelfTestReport::default(), None);
        assert!(html.contains("No negative probes ran"));
        assert!(html.contains("No operations."));
    }

    #[test]
    fn html_caps_missed_detail_at_200_rows() {
        let mut report = SelfTestReport::default();
        for i in 0..250 {
            report.operations.push(OperationResult {
                method: "GET".into(),
                path: format!("/r/{i}"),
                positive: None,
                negatives: vec![CaseOutcome {
                    label: "parameters:missing-query".into(),
                    expected_4xx: true,
                    actual_status: 200,
                    passed: false,
                }],
            });
        }
        report.negative_missed.insert("parameters".into(), 250);
        let html = render_html(&report, None);
        // Cap message visible:
        assert!(html.contains("250 missed negative"));
        // 200 rows means 200 occurrences of `<code>GET</code>` in the
        // detail table — plus more in the per-operation roll-up
        // table. The detail cap message is the load-bearing assertion.
        assert!(html.contains("Showing up to 200"));
    }
}