Skip to main content

mockforge_bench/conformance/
report_html.rs

1//! HTML report renderer for the conformance self-test.
2//!
3//! Issue #79 round 17.6 — Srikanth's (17.6) ask: a human-readable
4//! report grouped by category, severity, and OWASP class. The JSON
5//! reports from rounds 17.x are precise but hard to skim under a
6//! deadline; this module renders a self-contained HTML file that
7//! drops into a browser without any external assets.
8//!
9//! Sections:
10//! 1. Header: target URL, timestamp, headline counts.
11//! 2. Self-test summary cards: positives, negatives caught / missed
12//!    per category.
13//! 3. Negative detail table (rolled up by category + label) so a
14//!    user can drill from "owasp had 12 misses" → which routes.
15//! 4. Optional spec-audit section (if a round-17.4 audit JSON is
16//!    passed in alongside).
17//!
18//! Output is one self-contained HTML string: inline CSS, no external
19//! fonts or scripts, safe to email or commit to a CI artefact bucket.
20
21use super::self_test::{CaseOutcome, OperationResult, SelfTestReport};
22use std::collections::BTreeMap;
23
24/// Render a complete HTML report for the given self-test report.
25/// `audit` is an optional `SpecAuditReport`-shaped JSON value — when
26/// present, an audit section is appended. We accept `&serde_json::Value`
27/// rather than the strongly-typed `SpecAuditReport` to keep this
28/// module decoupled from `spec_audit` (which lives on a separate
29/// in-flight branch).
30pub fn render_html(report: &SelfTestReport, audit: Option<&serde_json::Value>) -> String {
31    render_html_with_options(report, audit, &RenderOptions::default())
32}
33
34/// Round 21.1 — render options surfaced via CLI flags. Currently:
35/// - `missed_cap`: max rows in the missed-negative drill-down table.
36///   `Some(N)` caps at N (default 200); `None` shows all rows. Set
37///   via `--report-missed-cap` (with `--report-missed-cap 0` mapping
38///   to None for "no cap").
39#[derive(Debug, Clone)]
40pub struct RenderOptions {
41    pub missed_cap: Option<usize>,
42}
43
44impl Default for RenderOptions {
45    fn default() -> Self {
46        Self {
47            missed_cap: Some(200),
48        }
49    }
50}
51
52/// Round 21.1 — like `render_html` but lets the caller override the
53/// drill-down cap. Used by the CLI when `--report-missed-cap` is set.
54pub fn render_html_with_options(
55    report: &SelfTestReport,
56    audit: Option<&serde_json::Value>,
57    opts: &RenderOptions,
58) -> String {
59    let mut html = String::new();
60    html.push_str(HEAD);
61    push_header(&mut html, report);
62    push_summary_cards(&mut html, report);
63    push_category_table(&mut html, report);
64    push_operations_table(&mut html, report, opts);
65    if let Some(a) = audit {
66        push_spec_audit(&mut html, a);
67    }
68    html.push_str(FOOT);
69    html
70}
71
72/// Inline-CSS opening — no external assets, prints fine.
73const HEAD: &str = r#"<!doctype html>
74<html lang="en">
75<head>
76<meta charset="utf-8">
77<title>MockForge Conformance Report</title>
78<style>
79  body { font-family: -apple-system, system-ui, sans-serif; max-width: 1100px;
80         margin: 2rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.5; }
81  h1 { font-size: 1.8rem; margin: 0 0 0.5rem; }
82  h2 { font-size: 1.3rem; margin: 2rem 0 0.5rem; border-bottom: 1px solid #d1d5db; padding-bottom: 0.3rem; }
83  .meta { color: #6b7280; font-size: 0.9rem; }
84  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin: 1rem 0; }
85  .card { padding: 0.75rem 1rem; border-radius: 6px; background: #f3f4f6; }
86  .card .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
87  .card .value { font-size: 1.6rem; font-weight: 600; color: #1f2933; }
88  .card.ok { background: #ecfdf5; } .card.ok .value { color: #047857; }
89  .card.warn { background: #fffbeb; } .card.warn .value { color: #b45309; }
90  .card.err { background: #fef2f2; } .card.err .value { color: #b91c1c; }
91  table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1.5rem; font-size: 0.9rem; }
92  th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
93  th { background: #f9fafb; font-weight: 600; color: #374151; }
94  tr:hover { background: #f9fafb; }
95  .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
96  .badge.pass { background: #d1fae5; color: #047857; }
97  .badge.fail { background: #fee2e2; color: #b91c1c; }
98  .badge.info { background: #dbeafe; color: #1d4ed8; }
99  .badge.warn { background: #fef3c7; color: #92400e; }
100  .badge.err  { background: #fee2e2; color: #b91c1c; }
101  .small { color: #6b7280; font-size: 0.85rem; }
102  code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
103</style>
104</head>
105<body>
106"#;
107
108const FOOT: &str = "\n</body>\n</html>\n";
109
110fn push_header(out: &mut String, _report: &SelfTestReport) {
111    out.push_str("<h1>MockForge Conformance Report</h1>\n");
112    out.push_str(
113        "<p class=\"meta\">Generated by <code>mockforge bench --conformance-self-test</code></p>\n",
114    );
115}
116
117fn push_summary_cards(out: &mut String, report: &SelfTestReport) {
118    let positives = report.positive_pass + report.positive_fail;
119    let neg_caught: usize = report.negative_caught.values().sum();
120    let neg_missed: usize = report.negative_missed.values().sum();
121    let pos_class = if report.positive_fail == 0 {
122        "ok"
123    } else {
124        "err"
125    };
126    let miss_class = if neg_missed == 0 { "ok" } else { "warn" };
127    out.push_str("<div class=\"cards\">\n");
128    push_card(out, "Positive cases", positives, pos_class);
129    push_card(out, "Positive failures", report.positive_fail, pos_class);
130    push_card(out, "Negatives caught", neg_caught, "ok");
131    push_card(out, "Negatives missed", neg_missed, miss_class);
132    push_card(out, "Operations", report.operations.len(), "");
133    out.push_str("</div>\n");
134}
135
136fn push_card(out: &mut String, label: &str, value: usize, class: &str) {
137    let class_attr = if class.is_empty() {
138        String::new()
139    } else {
140        format!(" {}", class)
141    };
142    out.push_str(&format!(
143        "  <div class=\"card{class_attr}\"><div class=\"label\">{}</div><div class=\"value\">{}</div></div>\n",
144        html_escape(label),
145        value
146    ));
147}
148
149fn push_category_table(out: &mut String, report: &SelfTestReport) {
150    out.push_str("<h2>Negatives by category</h2>\n");
151    let mut keys: Vec<&String> =
152        report.negative_caught.keys().chain(report.negative_missed.keys()).collect();
153    keys.sort();
154    keys.dedup();
155    if keys.is_empty() {
156        out.push_str("<p class=\"small\">No negative probes ran — typically means no operations had any injectable surface.</p>\n");
157        return;
158    }
159    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");
160    for cat in keys {
161        let caught = report.negative_caught.get(cat).copied().unwrap_or(0);
162        let missed = report.negative_missed.get(cat).copied().unwrap_or(0);
163        let (badge_class, badge_text) = if missed == 0 {
164            ("pass", "all caught")
165        } else {
166            ("fail", "gaps")
167        };
168        out.push_str(&format!(
169            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td></tr>\n",
170            html_escape(cat),
171            caught,
172            missed,
173            badge_class,
174            badge_text
175        ));
176    }
177    out.push_str("</tbody></table>\n");
178}
179
180fn push_operations_table(out: &mut String, report: &SelfTestReport, opts: &RenderOptions) {
181    out.push_str("<h2>Per-operation results</h2>\n");
182    if report.operations.is_empty() {
183        out.push_str("<p class=\"small\">No operations.</p>\n");
184        return;
185    }
186    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");
187    for op in &report.operations {
188        let pos_badge = match &op.positive {
189            Some(p) if p.passed => "<span class=\"badge pass\">2xx ✓</span>".to_string(),
190            Some(p) => format!("<span class=\"badge fail\">{} ✗</span>", p.actual_status),
191            None => "<span class=\"badge info\">none</span>".into(),
192        };
193        let (caught, missed) = op.negatives.iter().partition::<Vec<&CaseOutcome>, _>(|n| n.passed);
194        out.push_str(&format!(
195            "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td><td>{} caught / {} missed</td></tr>\n",
196            html_escape(&op.method),
197            html_escape(&op.path),
198            pos_badge,
199            caught.len(),
200            missed.len()
201        ));
202    }
203    out.push_str("</tbody></table>\n");
204    push_missed_detail(out, report, opts);
205}
206
207/// Round 21.1 — human-readable expected status range derived from the
208/// probe's `expected_4xx` flag, so the missed-negative table tells you
209/// what status it WAS expecting alongside what it ACTUALLY saw.
210fn expected_status_label(case: &CaseOutcome) -> &'static str {
211    if case.expected_4xx {
212        "4xx (reject)"
213    } else {
214        "2xx-3xx (accept)"
215    }
216}
217
218fn push_missed_detail(out: &mut String, report: &SelfTestReport, opts: &RenderOptions) {
219    // List every individual missed negative for drill-down. By default
220    // capped at 200 rows to keep the HTML file under a reasonable size
221    // on huge specs; raise or remove via `--report-missed-cap`. The
222    // JSON report always has the full set.
223    let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
224    for op in &report.operations {
225        for neg in &op.negatives {
226            if !neg.passed {
227                missed.push((op, neg));
228            }
229        }
230    }
231    if missed.is_empty() {
232        return;
233    }
234    out.push_str("<h2>Missed negatives (validator gaps)</h2>\n");
235    // Cap message: surface the cap explicitly so the user knows
236    // whether the table is truncated or complete.
237    let total = missed.len();
238    let cap_msg = match opts.missed_cap {
239        Some(cap) if total > cap => format!(
240            "{} missed negative(s). Showing first {} (raise with <code>--report-missed-cap N</code>, or <code>0</code> for no cap); full set in <code>conformance-self-test.json</code>.",
241            total, cap
242        ),
243        Some(_) => format!("{} missed negative(s). All shown.", total),
244        None => format!("{} missed negative(s). All shown (no cap).", total),
245    };
246    out.push_str(&format!("<p class=\"small\">{cap_msg}</p>\n"));
247    out.push_str("<table>\n<thead><tr><th>Method</th><th>Path</th><th>Label</th><th>Expected</th><th>Actual</th></tr></thead>\n<tbody>\n");
248    let take = opts.missed_cap.unwrap_or(usize::MAX);
249    for (op, neg) in missed.iter().take(take) {
250        out.push_str(&format!(
251            "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td><td><span class=\"badge info\">{}</span></td><td>{}</td></tr>\n",
252            html_escape(&op.method),
253            html_escape(&op.path),
254            html_escape(&neg.label),
255            expected_status_label(neg),
256            neg.actual_status
257        ));
258    }
259    out.push_str("</tbody></table>\n");
260}
261
262fn push_spec_audit(out: &mut String, audit: &serde_json::Value) {
263    out.push_str("<h2>Spec audit</h2>\n");
264    let findings = audit.get("findings").and_then(|v| v.as_array());
265    let coverage = audit.get("datatype_coverage").and_then(|v| v.as_object());
266    let ops = audit.get("operations_audited").and_then(|v| v.as_u64()).unwrap_or(0);
267    out.push_str(&format!(
268        "<p class=\"small\">Audited {ops} operation(s). Coverage map: {} datatype kind(s).</p>\n",
269        coverage.map(|c| c.len()).unwrap_or(0)
270    ));
271    if let Some(findings) = findings {
272        if findings.is_empty() {
273            out.push_str("<p class=\"small\">No findings.</p>\n");
274        } else {
275            // Group findings by severity for an easy scan.
276            let mut by_sev: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
277            for f in findings {
278                let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
279                by_sev.entry(sev).or_default().push(f);
280            }
281            out.push_str("<table>\n<thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Message</th></tr></thead>\n<tbody>\n");
282            for (sev, items) in by_sev {
283                let badge_class = match sev.as_str() {
284                    "error" => "err",
285                    "warning" => "warn",
286                    _ => "info",
287                };
288                for item in items {
289                    let cat = item.get("category").and_then(|v| v.as_str()).unwrap_or("");
290                    let loc = item.get("location").and_then(|v| v.as_str()).unwrap_or("");
291                    let msg = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
292                    out.push_str(&format!(
293                        "<tr><td><span class=\"badge {}\">{}</span></td><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>\n",
294                        badge_class,
295                        html_escape(&sev),
296                        html_escape(cat),
297                        html_escape(loc),
298                        html_escape(msg)
299                    ));
300                }
301            }
302            out.push_str("</tbody></table>\n");
303        }
304    }
305    if let Some(coverage) = coverage {
306        let mut entries: Vec<(&String, u64)> =
307            coverage.iter().filter_map(|(k, v)| v.as_u64().map(|c| (k, c))).collect();
308        entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
309        if !entries.is_empty() {
310            out.push_str("<h2>Datatype coverage</h2>\n");
311            out.push_str("<table>\n<thead><tr><th>Type</th><th>Count</th></tr></thead>\n<tbody>\n");
312            for (kind, count) in entries.iter().take(40) {
313                out.push_str(&format!(
314                    "<tr><td><code>{}</code></td><td>{}</td></tr>\n",
315                    html_escape(kind),
316                    count
317                ));
318            }
319            out.push_str("</tbody></table>\n");
320        }
321    }
322}
323
324fn html_escape(s: &str) -> String {
325    let mut out = String::with_capacity(s.len());
326    for c in s.chars() {
327        match c {
328            '&' => out.push_str("&amp;"),
329            '<' => out.push_str("&lt;"),
330            '>' => out.push_str("&gt;"),
331            '"' => out.push_str("&quot;"),
332            '\'' => out.push_str("&#39;"),
333            _ => out.push(c),
334        }
335    }
336    out
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::conformance::self_test::{CaseOutcome, OperationResult, SelfTestReport};
343
344    fn sample_report() -> SelfTestReport {
345        SelfTestReport {
346            positive_pass: 3,
347            positive_fail: 1,
348            negative_caught: BTreeMap::from([("request-body".into(), 4), ("parameters".into(), 2)]),
349            negative_missed: BTreeMap::from([("owasp".into(), 1)]),
350            operations: vec![OperationResult {
351                method: "POST".into(),
352                path: "/users".into(),
353                positive: Some(CaseOutcome {
354                    label: "positive".into(),
355                    expected_4xx: false,
356                    actual_status: 201,
357                    passed: true,
358                }),
359                negatives: vec![CaseOutcome {
360                    label: "owasp:sqli".into(),
361                    expected_4xx: true,
362                    actual_status: 200,
363                    passed: false,
364                }],
365            }],
366        }
367    }
368
369    #[test]
370    fn html_contains_expected_sections() {
371        let html = render_html(&sample_report(), None);
372        assert!(html.contains("<title>MockForge Conformance Report</title>"));
373        assert!(html.contains("Positive cases"));
374        assert!(html.contains("Negatives by category"));
375        assert!(html.contains("Per-operation results"));
376        assert!(html.contains("Missed negatives"));
377        // Specific data points from the sample report:
378        assert!(html.contains("request-body"));
379        assert!(html.contains("owasp:sqli"));
380        assert!(html.contains("/users"));
381    }
382
383    #[test]
384    fn html_renders_audit_section_when_present() {
385        let audit = serde_json::json!({
386            "findings": [
387                {"category": "servers", "severity": "warning",
388                 "location": "#/servers", "message": "no servers declared"}
389            ],
390            "datatype_coverage": {"string": 5, "integer": 3},
391            "operations_audited": 7
392        });
393        let html = render_html(&sample_report(), Some(&audit));
394        assert!(html.contains("Spec audit"));
395        assert!(html.contains("no servers declared"));
396        assert!(html.contains("Datatype coverage"));
397        assert!(html.contains("string"));
398        assert!(html.contains("Audited 7 operation"));
399    }
400
401    #[test]
402    fn html_escapes_special_chars_in_labels() {
403        let mut report = sample_report();
404        report.operations[0].path = "/items/<script>".into();
405        report.operations[0].negatives[0].label = "owasp:xss:<>\"&".into();
406        let html = render_html(&report, None);
407        // The literal special chars should be escaped, not rendered raw.
408        assert!(!html.contains("/items/<script>"));
409        assert!(html.contains("&lt;script&gt;"));
410        assert!(html.contains("&quot;"));
411    }
412
413    #[test]
414    fn html_handles_empty_report() {
415        let html = render_html(&SelfTestReport::default(), None);
416        assert!(html.contains("No negative probes ran"));
417        assert!(html.contains("No operations."));
418    }
419
420    #[test]
421    fn html_caps_missed_detail_at_default_200_rows() {
422        let mut report = SelfTestReport::default();
423        for i in 0..250 {
424            report.operations.push(OperationResult {
425                method: "GET".into(),
426                path: format!("/r/{i}"),
427                positive: None,
428                negatives: vec![CaseOutcome {
429                    label: "parameters:missing-query".into(),
430                    expected_4xx: true,
431                    actual_status: 200,
432                    passed: false,
433                }],
434            });
435        }
436        report.negative_missed.insert("parameters".into(), 250);
437        let html = render_html(&report, None);
438        // Cap message visible and references the new flag:
439        assert!(html.contains("250 missed negative"));
440        assert!(html.contains("Showing first 200"));
441        assert!(html.contains("--report-missed-cap"));
442    }
443
444    /// Round 21.1 — when `missed_cap` is `None` (set via
445    /// `--report-missed-cap 0`), all rows are shown and the message
446    /// says so explicitly.
447    #[test]
448    fn html_no_cap_shows_all_rows() {
449        let mut report = SelfTestReport::default();
450        for i in 0..50 {
451            report.operations.push(OperationResult {
452                method: "GET".into(),
453                path: format!("/r/{i}"),
454                positive: None,
455                negatives: vec![CaseOutcome {
456                    label: "parameters:missing-query".into(),
457                    expected_4xx: true,
458                    actual_status: 200,
459                    passed: false,
460                }],
461            });
462        }
463        let opts = RenderOptions { missed_cap: None };
464        let html = render_html_with_options(&report, None, &opts);
465        assert!(html.contains("50 missed negative"));
466        assert!(html.contains("All shown (no cap)"));
467        assert!(!html.contains("Showing first"));
468    }
469
470    /// Round 21.1 — the missed-negative table now has an Expected
471    /// column derived from the probe's `expected_4xx` flag.
472    #[test]
473    fn html_missed_table_has_expected_column() {
474        let mut report = sample_report();
475        // The sample's single missed negative is `owasp:sqli` with
476        // expected_4xx: true.
477        report.operations[0].negatives = vec![CaseOutcome {
478            label: "security:bad-bearer".into(),
479            expected_4xx: true,
480            actual_status: 200,
481            passed: false,
482        }];
483        let html = render_html(&report, None);
484        assert!(html.contains("Expected"), "Expected column header missing");
485        assert!(
486            html.contains("4xx (reject)"),
487            "expected-status badge for negative probe missing"
488        );
489    }
490}