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    // Round 22.6 — link the probe-label reference next to the
113    // generator credit so users can decode labels like
114    // `request-body:type-mismatch:user.email` without leaving
115    // the report. The book is generated separately, so we link
116    // to the canonical hosted location.
117    out.push_str(
118        "<p class=\"meta\">Generated by <code>mockforge bench --conformance-self-test</code>. \
119         Probe-label reference: \
120         <a href=\"https://docs.mockforge.dev/reference/conformance-self-test-probes.html\">\
121         docs.mockforge.dev/reference/conformance-self-test-probes</a>.</p>\n",
122    );
123}
124
125fn push_summary_cards(out: &mut String, report: &SelfTestReport) {
126    let positives = report.positive_pass + report.positive_fail;
127    let neg_caught: usize = report.negative_caught.values().sum();
128    let neg_missed: usize = report.negative_missed.values().sum();
129    let pos_class = if report.positive_fail == 0 {
130        "ok"
131    } else {
132        "err"
133    };
134    let miss_class = if neg_missed == 0 { "ok" } else { "warn" };
135    out.push_str("<div class=\"cards\">\n");
136    push_card(out, "Positive cases", positives, pos_class);
137    push_card(out, "Positive failures", report.positive_fail, pos_class);
138    push_card(out, "Negatives caught", neg_caught, "ok");
139    push_card(out, "Negatives missed", neg_missed, miss_class);
140    push_card(out, "Operations", report.operations.len(), "");
141    out.push_str("</div>\n");
142}
143
144fn push_card(out: &mut String, label: &str, value: usize, class: &str) {
145    let class_attr = if class.is_empty() {
146        String::new()
147    } else {
148        format!(" {}", class)
149    };
150    out.push_str(&format!(
151        "  <div class=\"card{class_attr}\"><div class=\"label\">{}</div><div class=\"value\">{}</div></div>\n",
152        html_escape(label),
153        value
154    ));
155}
156
157fn push_category_table(out: &mut String, report: &SelfTestReport) {
158    out.push_str("<h2>Negatives by category</h2>\n");
159    let mut keys: Vec<&String> =
160        report.negative_caught.keys().chain(report.negative_missed.keys()).collect();
161    keys.sort();
162    keys.dedup();
163    if keys.is_empty() {
164        out.push_str("<p class=\"small\">No negative probes ran — typically means no operations had any injectable surface.</p>\n");
165        return;
166    }
167    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");
168    for cat in keys {
169        let caught = report.negative_caught.get(cat).copied().unwrap_or(0);
170        let missed = report.negative_missed.get(cat).copied().unwrap_or(0);
171        // Round 22.6 — "gaps" was too vague; users had to guess
172        // whether it meant a spec gap, a server bug, or a validator
173        // miss. The replacement names what the badge actually
174        // measures: server returned a non-4xx to a probe that
175        // expected 4xx, so something between the spec and the
176        // server let it through.
177        let (badge_class, badge_text) = if missed == 0 {
178            ("pass", "all caught")
179        } else {
180            ("fail", "rejection gaps")
181        };
182        out.push_str(&format!(
183            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td></tr>\n",
184            html_escape(cat),
185            caught,
186            missed,
187            badge_class,
188            badge_text
189        ));
190    }
191    out.push_str("</tbody></table>\n");
192}
193
194fn push_operations_table(out: &mut String, report: &SelfTestReport, opts: &RenderOptions) {
195    out.push_str("<h2>Per-operation results</h2>\n");
196    if report.operations.is_empty() {
197        out.push_str("<p class=\"small\">No operations.</p>\n");
198        return;
199    }
200    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");
201    for op in &report.operations {
202        let pos_badge = match &op.positive {
203            Some(p) if p.passed => "<span class=\"badge pass\">2xx ✓</span>".to_string(),
204            Some(p) => format!("<span class=\"badge fail\">{} ✗</span>", p.actual_status),
205            None => "<span class=\"badge info\">none</span>".into(),
206        };
207        let (caught, missed) = op.negatives.iter().partition::<Vec<&CaseOutcome>, _>(|n| n.passed);
208        out.push_str(&format!(
209            "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td><td>{} caught / {} missed</td></tr>\n",
210            html_escape(&op.method),
211            html_escape(&op.path),
212            pos_badge,
213            caught.len(),
214            missed.len()
215        ));
216    }
217    out.push_str("</tbody></table>\n");
218    push_missed_detail(out, report, opts);
219}
220
221/// Round 21.1 — human-readable expected status range derived from the
222/// probe's `expected_4xx` flag, so the missed-negative table tells you
223/// what status it WAS expecting alongside what it ACTUALLY saw.
224fn expected_status_label(case: &CaseOutcome) -> &'static str {
225    if case.expected_4xx {
226        "4xx (reject)"
227    } else {
228        "2xx-3xx (accept)"
229    }
230}
231
232fn push_missed_detail(out: &mut String, report: &SelfTestReport, opts: &RenderOptions) {
233    // List every individual missed negative for drill-down. By default
234    // capped at 200 rows to keep the HTML file under a reasonable size
235    // on huge specs; raise or remove via `--report-missed-cap`. The
236    // JSON report always has the full set.
237    let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
238    for op in &report.operations {
239        for neg in &op.negatives {
240            if !neg.passed {
241                missed.push((op, neg));
242            }
243        }
244    }
245    if missed.is_empty() {
246        return;
247    }
248    out.push_str("<h2>Missed negatives (validator gaps)</h2>\n");
249    // Cap message: surface the cap explicitly so the user knows
250    // whether the table is truncated or complete.
251    let total = missed.len();
252    let cap_msg = match opts.missed_cap {
253        Some(cap) if total > cap => format!(
254            "{} 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>.",
255            total, cap
256        ),
257        Some(_) => format!("{} missed negative(s). All shown.", total),
258        None => format!("{} missed negative(s). All shown (no cap).", total),
259    };
260    out.push_str(&format!("<p class=\"small\">{cap_msg}</p>\n"));
261    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");
262    let take = opts.missed_cap.unwrap_or(usize::MAX);
263    for (op, neg) in missed.iter().take(take) {
264        out.push_str(&format!(
265            "<tr><td><code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td><td><span class=\"badge info\">{}</span></td><td>{}</td></tr>\n",
266            html_escape(&op.method),
267            html_escape(&op.path),
268            html_escape(&neg.label),
269            expected_status_label(neg),
270            neg.actual_status
271        ));
272    }
273    out.push_str("</tbody></table>\n");
274}
275
276fn push_spec_audit(out: &mut String, audit: &serde_json::Value) {
277    out.push_str("<h2>Spec audit</h2>\n");
278    let findings = audit.get("findings").and_then(|v| v.as_array());
279    let coverage = audit.get("datatype_coverage").and_then(|v| v.as_object());
280    let ops = audit.get("operations_audited").and_then(|v| v.as_u64()).unwrap_or(0);
281    out.push_str(&format!(
282        "<p class=\"small\">Audited {ops} operation(s). Coverage map: {} datatype kind(s).</p>\n",
283        coverage.map(|c| c.len()).unwrap_or(0)
284    ));
285    if let Some(findings) = findings {
286        if findings.is_empty() {
287            out.push_str("<p class=\"small\">No findings.</p>\n");
288        } else {
289            // Group findings by severity for an easy scan.
290            let mut by_sev: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
291            for f in findings {
292                let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
293                by_sev.entry(sev).or_default().push(f);
294            }
295            out.push_str("<table>\n<thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Message</th></tr></thead>\n<tbody>\n");
296            for (sev, items) in by_sev {
297                let badge_class = match sev.as_str() {
298                    "error" => "err",
299                    "warning" => "warn",
300                    _ => "info",
301                };
302                for item in items {
303                    let cat = item.get("category").and_then(|v| v.as_str()).unwrap_or("");
304                    let loc = item.get("location").and_then(|v| v.as_str()).unwrap_or("");
305                    let msg = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
306                    out.push_str(&format!(
307                        "<tr><td><span class=\"badge {}\">{}</span></td><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>\n",
308                        badge_class,
309                        html_escape(&sev),
310                        html_escape(cat),
311                        html_escape(loc),
312                        html_escape(msg)
313                    ));
314                }
315            }
316            out.push_str("</tbody></table>\n");
317        }
318    }
319    if let Some(coverage) = coverage {
320        let mut entries: Vec<(&String, u64)> =
321            coverage.iter().filter_map(|(k, v)| v.as_u64().map(|c| (k, c))).collect();
322        entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
323        if !entries.is_empty() {
324            out.push_str("<h2>Datatype coverage</h2>\n");
325            out.push_str("<table>\n<thead><tr><th>Type</th><th>Count</th></tr></thead>\n<tbody>\n");
326            for (kind, count) in entries.iter().take(40) {
327                out.push_str(&format!(
328                    "<tr><td><code>{}</code></td><td>{}</td></tr>\n",
329                    html_escape(kind),
330                    count
331                ));
332            }
333            out.push_str("</tbody></table>\n");
334        }
335    }
336}
337
338fn html_escape(s: &str) -> String {
339    let mut out = String::with_capacity(s.len());
340    for c in s.chars() {
341        match c {
342            '&' => out.push_str("&amp;"),
343            '<' => out.push_str("&lt;"),
344            '>' => out.push_str("&gt;"),
345            '"' => out.push_str("&quot;"),
346            '\'' => out.push_str("&#39;"),
347            _ => out.push(c),
348        }
349    }
350    out
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::conformance::self_test::{CaseOutcome, OperationResult, SelfTestReport};
357
358    fn sample_report() -> SelfTestReport {
359        SelfTestReport {
360            positive_pass: 3,
361            positive_fail: 1,
362            negative_caught: BTreeMap::from([("request-body".into(), 4), ("parameters".into(), 2)]),
363            negative_missed: BTreeMap::from([("owasp".into(), 1)]),
364            operations: vec![OperationResult {
365                method: "POST".into(),
366                path: "/users".into(),
367                positive: Some(CaseOutcome {
368                    label: "positive".into(),
369                    expected_4xx: false,
370                    actual_status: 201,
371                    passed: true,
372                }),
373                negatives: vec![CaseOutcome {
374                    label: "owasp:sqli".into(),
375                    expected_4xx: true,
376                    actual_status: 200,
377                    passed: false,
378                }],
379            }],
380        }
381    }
382
383    #[test]
384    fn html_contains_expected_sections() {
385        let html = render_html(&sample_report(), None);
386        assert!(html.contains("<title>MockForge Conformance Report</title>"));
387        assert!(html.contains("Positive cases"));
388        assert!(html.contains("Negatives by category"));
389        assert!(html.contains("Per-operation results"));
390        assert!(html.contains("Missed negatives"));
391        // Specific data points from the sample report:
392        assert!(html.contains("request-body"));
393        assert!(html.contains("owasp:sqli"));
394        assert!(html.contains("/users"));
395    }
396
397    #[test]
398    fn html_renders_audit_section_when_present() {
399        let audit = serde_json::json!({
400            "findings": [
401                {"category": "servers", "severity": "warning",
402                 "location": "#/servers", "message": "no servers declared"}
403            ],
404            "datatype_coverage": {"string": 5, "integer": 3},
405            "operations_audited": 7
406        });
407        let html = render_html(&sample_report(), Some(&audit));
408        assert!(html.contains("Spec audit"));
409        assert!(html.contains("no servers declared"));
410        assert!(html.contains("Datatype coverage"));
411        assert!(html.contains("string"));
412        assert!(html.contains("Audited 7 operation"));
413    }
414
415    #[test]
416    fn html_escapes_special_chars_in_labels() {
417        let mut report = sample_report();
418        report.operations[0].path = "/items/<script>".into();
419        report.operations[0].negatives[0].label = "owasp:xss:<>\"&".into();
420        let html = render_html(&report, None);
421        // The literal special chars should be escaped, not rendered raw.
422        assert!(!html.contains("/items/<script>"));
423        assert!(html.contains("&lt;script&gt;"));
424        assert!(html.contains("&quot;"));
425    }
426
427    #[test]
428    fn html_handles_empty_report() {
429        let html = render_html(&SelfTestReport::default(), None);
430        assert!(html.contains("No negative probes ran"));
431        assert!(html.contains("No operations."));
432    }
433
434    #[test]
435    fn html_caps_missed_detail_at_default_200_rows() {
436        let mut report = SelfTestReport::default();
437        for i in 0..250 {
438            report.operations.push(OperationResult {
439                method: "GET".into(),
440                path: format!("/r/{i}"),
441                positive: None,
442                negatives: vec![CaseOutcome {
443                    label: "parameters:missing-query".into(),
444                    expected_4xx: true,
445                    actual_status: 200,
446                    passed: false,
447                }],
448            });
449        }
450        report.negative_missed.insert("parameters".into(), 250);
451        let html = render_html(&report, None);
452        // Cap message visible and references the new flag:
453        assert!(html.contains("250 missed negative"));
454        assert!(html.contains("Showing first 200"));
455        assert!(html.contains("--report-missed-cap"));
456    }
457
458    /// Round 21.1 — when `missed_cap` is `None` (set via
459    /// `--report-missed-cap 0`), all rows are shown and the message
460    /// says so explicitly.
461    #[test]
462    fn html_no_cap_shows_all_rows() {
463        let mut report = SelfTestReport::default();
464        for i in 0..50 {
465            report.operations.push(OperationResult {
466                method: "GET".into(),
467                path: format!("/r/{i}"),
468                positive: None,
469                negatives: vec![CaseOutcome {
470                    label: "parameters:missing-query".into(),
471                    expected_4xx: true,
472                    actual_status: 200,
473                    passed: false,
474                }],
475            });
476        }
477        let opts = RenderOptions { missed_cap: None };
478        let html = render_html_with_options(&report, None, &opts);
479        assert!(html.contains("50 missed negative"));
480        assert!(html.contains("All shown (no cap)"));
481        assert!(!html.contains("Showing first"));
482    }
483
484    /// Round 21.1 — the missed-negative table now has an Expected
485    /// column derived from the probe's `expected_4xx` flag.
486    #[test]
487    fn html_missed_table_has_expected_column() {
488        let mut report = sample_report();
489        // The sample's single missed negative is `owasp:sqli` with
490        // expected_4xx: true.
491        report.operations[0].negatives = vec![CaseOutcome {
492            label: "security:bad-bearer".into(),
493            expected_4xx: true,
494            actual_status: 200,
495            passed: false,
496        }];
497        let html = render_html(&report, None);
498        assert!(html.contains("Expected"), "Expected column header missing");
499        assert!(
500            html.contains("4xx (reject)"),
501            "expected-status badge for negative probe missing"
502        );
503    }
504}