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    // Round 24 (e) — pre-compute the set of categories and operation
64    // slugs that will actually appear in the truncated drill-down
65    // table, so the count-cells in the upper tables only link when
66    // the target anchor exists. Without this, a count linking to a
67    // row that got cropped by `--report-missed-cap` dead-ends.
68    let anchors = compute_anchor_set(report, opts);
69    // Round 26 — Srikanth on 0.3.169: the standalone `Negatives by
70    // category` table duplicated what the family table already shows.
71    // Now there's a single `Negatives by category` table with a Family
72    // column prepended; one row per category, grouped under its family.
73    // No separate family rollup section.
74    push_grouped_category_table(&mut html, report, &anchors);
75    push_operations_table(&mut html, report, opts, &anchors);
76    if let Some(a) = audit {
77        push_spec_audit(&mut html, a);
78    }
79    html.push_str(FOOT);
80    html
81}
82
83/// Round 24 (e) — for each (category, op_slug) that gets at least one
84/// row in the drill-down table under the current cap, record it here.
85/// The category and per-operation tables consult this set so a count
86/// only becomes a clickable link when the target row is actually
87/// rendered. Without this, capping at 200 rows on a 1000-violation
88/// run left every link past row 200 pointing into the void.
89fn compute_anchor_set(report: &SelfTestReport, opts: &RenderOptions) -> AnchorSet {
90    let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
91    for op in &report.operations {
92        for neg in &op.negatives {
93            if !neg.passed {
94                missed.push((op, neg));
95            }
96        }
97    }
98    let take = opts.missed_cap.unwrap_or(usize::MAX);
99    let mut cats: std::collections::HashSet<String> = std::collections::HashSet::new();
100    let mut ops: std::collections::HashSet<String> = std::collections::HashSet::new();
101    for (op, neg) in missed.iter().take(take) {
102        let cat = neg.label.split(':').next().unwrap_or("other").to_string();
103        cats.insert(cat);
104        ops.insert(op_anchor_slug(&op.method, &op.path));
105    }
106    AnchorSet { cats, ops }
107}
108
109/// Round 24 (e) — set of category names and operation slugs that have
110/// at least one anchored row in the drill-down table after the cap.
111#[derive(Default)]
112struct AnchorSet {
113    cats: std::collections::HashSet<String>,
114    ops: std::collections::HashSet<String>,
115}
116
117/// Inline-CSS opening — no external assets, prints fine.
118const HEAD: &str = r#"<!doctype html>
119<html lang="en">
120<head>
121<meta charset="utf-8">
122<title>MockForge Conformance Report</title>
123<style>
124  body { font-family: -apple-system, system-ui, sans-serif; max-width: 1100px;
125         margin: 2rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.5; }
126  h1 { font-size: 1.8rem; margin: 0 0 0.5rem; }
127  h2 { font-size: 1.3rem; margin: 2rem 0 0.5rem; border-bottom: 1px solid #d1d5db; padding-bottom: 0.3rem; }
128  .meta { color: #6b7280; font-size: 0.9rem; }
129  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin: 1rem 0; }
130  .card { padding: 0.75rem 1rem; border-radius: 6px; background: #f3f4f6; }
131  .card .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
132  .card .value { font-size: 1.6rem; font-weight: 600; color: #1f2933; }
133  .card.ok { background: #ecfdf5; } .card.ok .value { color: #047857; }
134  .card.warn { background: #fffbeb; } .card.warn .value { color: #b45309; }
135  .card.err { background: #fef2f2; } .card.err .value { color: #b91c1c; }
136  table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1.5rem; font-size: 0.9rem; }
137  th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
138  th { background: #f9fafb; font-weight: 600; color: #374151; }
139  tr:hover { background: #f9fafb; }
140  .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
141  .badge.pass { background: #d1fae5; color: #047857; }
142  .badge.fail { background: #fee2e2; color: #b91c1c; }
143  .badge.info { background: #dbeafe; color: #1d4ed8; }
144  .badge.warn { background: #fef3c7; color: #92400e; }
145  .badge.err  { background: #fee2e2; color: #b91c1c; }
146  .small { color: #6b7280; font-size: 0.85rem; }
147  code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
148</style>
149</head>
150<body>
151"#;
152
153const FOOT: &str = "\n</body>\n</html>\n";
154
155fn push_header(out: &mut String, _report: &SelfTestReport) {
156    out.push_str("<h1>MockForge Conformance Report</h1>\n");
157    // Round 22.6 — link the probe-label reference next to the
158    // generator credit so users can decode labels like
159    // `request-body:type-mismatch:user.email` without leaving
160    // the report. The book is generated separately, so we link
161    // to the canonical hosted location.
162    out.push_str(
163        "<p class=\"meta\">Generated by <code>mockforge bench --conformance-self-test</code>. \
164         Probe-label reference: \
165         <a href=\"https://docs.mockforge.dev/reference/conformance-self-test-probes.html\">\
166         docs.mockforge.dev/reference/conformance-self-test-probes</a>.</p>\n",
167    );
168}
169
170fn push_summary_cards(out: &mut String, report: &SelfTestReport) {
171    let positives = report.positive_pass + report.positive_fail;
172    let neg_caught: usize = report.negative_caught.values().sum();
173    let neg_missed: usize = report.negative_missed.values().sum();
174    let pos_class = if report.positive_fail == 0 {
175        "ok"
176    } else {
177        "err"
178    };
179    let miss_class = if neg_missed == 0 { "ok" } else { "warn" };
180    out.push_str("<div class=\"cards\">\n");
181    push_card(out, "Positive cases", positives, pos_class);
182    push_card(out, "Positive failures", report.positive_fail, pos_class);
183    push_card(out, "Negatives matched (4xx)", neg_caught, "ok");
184    push_card(out, "Negatives mismatched (non-4xx)", neg_missed, miss_class);
185    push_card(out, "Operations", report.operations.len(), "");
186    out.push_str("</div>\n");
187}
188
189fn push_card(out: &mut String, label: &str, value: usize, class: &str) {
190    let class_attr = if class.is_empty() {
191        String::new()
192    } else {
193        format!(" {}", class)
194    };
195    out.push_str(&format!(
196        "  <div class=\"card{class_attr}\"><div class=\"label\">{}</div><div class=\"value\">{}</div></div>\n",
197        html_escape(label),
198        value
199    ));
200}
201
202/// Round 26 — single category table with a Family column prepended.
203/// Each row is still one category with its own Matched/Mismatched
204/// counts, PASS/FAIL badge, and clickable mismatch count anchor; the
205/// new Family column groups categories under a family name so the
206/// reader sees both the per-category resolution Srikanth wanted to
207/// keep AND the family rollup in one glance. Categories with no
208/// family fall back to `<code>other</code>` in the Family column.
209/// Rows are sorted by family first then category, so all members of a
210/// family render contiguously.
211fn push_grouped_category_table(out: &mut String, report: &SelfTestReport, anchors: &AnchorSet) {
212    out.push_str("<h2>Negatives by category</h2>\n");
213    let mut keys: Vec<&String> =
214        report.negative_caught.keys().chain(report.negative_missed.keys()).collect();
215    keys.sort();
216    keys.dedup();
217    if keys.is_empty() {
218        out.push_str("<p class=\"small\">No negative probes ran — typically means no operations had any injectable surface.</p>\n");
219        return;
220    }
221    // (family, category) tuples so we can sort by family first.
222    let mut rows: Vec<(&'static str, &String)> =
223        keys.into_iter().map(|c| (family_for_category(c), c)).collect();
224    rows.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
225    out.push_str("<table>\n<thead><tr><th>Family</th><th>Category</th><th>Matched (4xx)</th><th>Mismatched (non-4xx)</th><th>Status</th></tr></thead>\n<tbody>\n");
226    for (family, cat) in rows {
227        let caught = report.negative_caught.get(cat).copied().unwrap_or(0);
228        let missed = report.negative_missed.get(cat).copied().unwrap_or(0);
229        let (badge_class, badge_text) = if missed == 0 {
230            ("pass", "PASS")
231        } else {
232            ("fail", "FAIL")
233        };
234        // Same clickable-count + cap-aware logic as rounds 23/24.
235        let missed_cell = if missed > 0 && anchors.cats.contains(cat) {
236            format!("<a href=\"#miss-cat-{}\">{}</a>", html_escape(cat), missed)
237        } else {
238            missed.to_string()
239        };
240        out.push_str(&format!(
241            "<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td></tr>\n",
242            html_escape(family),
243            html_escape(cat),
244            caught,
245            missed_cell,
246            badge_class,
247            badge_text
248        ));
249    }
250    out.push_str("</tbody></table>\n");
251}
252
253/// Round 26 — map a category label prefix to its display family name.
254/// Mirrors the membership in the deleted `push_family_table`. Keeping
255/// the map in one place so future probes (e.g. round-25's
256/// content-type-mismatch) are easy to slot into the right family.
257fn family_for_category(cat: &str) -> &'static str {
258    match cat {
259        "request-body" => "Request body",
260        "parameters" => "Parameters",
261        "security" | "owasp" => "Security",
262        _ => "other",
263    }
264}
265
266fn push_operations_table(
267    out: &mut String,
268    report: &SelfTestReport,
269    opts: &RenderOptions,
270    anchors: &AnchorSet,
271) {
272    out.push_str("<h2>Per-operation results</h2>\n");
273    if report.operations.is_empty() {
274        out.push_str("<p class=\"small\">No operations.</p>\n");
275        return;
276    }
277    // Round 25 — added a `By category` column showing which categories
278    // each operation's mismatches came from. Comma-joined `cat:N` pairs
279    // make it scannable from the upper table without expanding the
280    // drill-down. Empty cell when the operation has zero mismatches.
281    out.push_str("<table>\n<thead><tr><th>Method</th><th>Path</th><th>Positive</th><th>Matched / Mismatched</th><th>By category</th></tr></thead>\n<tbody>\n");
282    for op in &report.operations {
283        let pos_badge = match &op.positive {
284            Some(p) if p.passed => "<span class=\"badge pass\">2xx ✓</span>".to_string(),
285            Some(p) => format!("<span class=\"badge fail\">{} ✗</span>", p.actual_status),
286            None => "<span class=\"badge info\">none</span>".into(),
287        };
288        let (caught, missed) = op.negatives.iter().partition::<Vec<&CaseOutcome>, _>(|n| n.passed);
289        // Round 23 (d) — clickable count: link the Mismatched count to
290        // the operation's anchor in the drill-down table below.
291        // Round 24 (e) — only link when the operation's first
292        // mismatched row survived the cap, otherwise the link is a
293        // dead anchor.
294        let op_slug = op_anchor_slug(&op.method, &op.path);
295        let missed_cell = if missed.is_empty() {
296            "0".to_string()
297        } else if anchors.ops.contains(&op_slug) {
298            format!("<a href=\"#miss-op-{}\">{}</a>", op_slug, missed.len())
299        } else {
300            missed.len().to_string()
301        };
302        // Round 25 — per-operation category breakdown: count
303        // mismatches grouped by their label's first segment. Sorted
304        // alphabetically for stable rendering.
305        let mut by_cat: BTreeMap<&str, usize> = BTreeMap::new();
306        for m in &missed {
307            let cat = m.label.split(':').next().unwrap_or("other");
308            *by_cat.entry(cat).or_insert(0) += 1;
309        }
310        // Round 28 — Srikanth's 0.3.171 ask: each `cat:N` here should
311        // be clickable like the upper Mismatched count, so the reader
312        // can jump straight to the category's drill-down rows. Same
313        // cap-aware rule as the per-row links above: only emit `<a>`
314        // when the category has at least one anchored row in the
315        // drill-down table.
316        let by_cat_cell = if by_cat.is_empty() {
317            String::new()
318        } else {
319            by_cat
320                .iter()
321                .map(|(cat, n)| {
322                    if anchors.cats.contains(*cat) {
323                        format!(
324                            "<code><a href=\"#miss-cat-{}\">{}:{}</a></code>",
325                            html_escape(cat),
326                            html_escape(cat),
327                            n
328                        )
329                    } else {
330                        format!("<code>{}:{}</code>", html_escape(cat), n)
331                    }
332                })
333                .collect::<Vec<_>>()
334                .join(" ")
335        };
336        out.push_str(&format!(
337            "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td><td>{} / {}</td><td>{}</td></tr>\n",
338            html_escape(&op.method),
339            html_escape(&op.path),
340            pos_badge,
341            caught.len(),
342            missed_cell,
343            by_cat_cell
344        ));
345    }
346    out.push_str("</tbody></table>\n");
347    push_missed_detail(out, report, opts);
348}
349
350/// Round 23 (d) — stable slug for the per-operation anchor in the
351/// missed-negative drill-down table. Lowercase, [a-z0-9_] only so the
352/// resulting `id` is HTML-valid and the `#miss-op-...` link from the
353/// Per-operation table resolves predictably. Collisions across very
354/// similar paths are acceptable: clicking lands on the first matching
355/// row and the table is short enough to scan from there.
356fn op_anchor_slug(method: &str, path: &str) -> String {
357    let mut s = format!("{method}_{path}");
358    s = s.to_ascii_lowercase();
359    s = s.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
360    s
361}
362
363/// Round 21.1 — human-readable expected status range derived from the
364/// probe's `expected_4xx` flag, so the missed-negative table tells you
365/// what status it WAS expecting alongside what it ACTUALLY saw.
366fn expected_status_label(case: &CaseOutcome) -> &'static str {
367    if case.expected_4xx {
368        "4xx (reject)"
369    } else {
370        "2xx-3xx (accept)"
371    }
372}
373
374fn push_missed_detail(out: &mut String, report: &SelfTestReport, opts: &RenderOptions) {
375    // List every individual missed negative for drill-down. By default
376    // capped at 200 rows to keep the HTML file under a reasonable size
377    // on huge specs; raise or remove via `--report-missed-cap`. The
378    // JSON report always has the full set.
379    let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
380    for op in &report.operations {
381        for neg in &op.negatives {
382            if !neg.passed {
383                missed.push((op, neg));
384            }
385        }
386    }
387    if missed.is_empty() {
388        return;
389    }
390    out.push_str(
391        "<h2>Mismatched negatives (server returned non-4xx to a probe expecting 4xx)</h2>\n",
392    );
393    // Cap message: surface the cap explicitly so the user knows
394    // whether the table is truncated or complete.
395    let total = missed.len();
396    let cap_msg = match opts.missed_cap {
397        Some(cap) if total > cap => format!(
398            "{} mismatched 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>.",
399            total, cap
400        ),
401        Some(_) => format!("{} mismatched negative(s). All shown.", total),
402        None => format!("{} mismatched negative(s). All shown (no cap).", total),
403    };
404    out.push_str(&format!("<p class=\"small\">{cap_msg}</p>\n"));
405    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");
406    let take = opts.missed_cap.unwrap_or(usize::MAX);
407    // Round 23 (d) — emit anchor ids so the count-cells in the
408    // Negatives-by-category and Per-operation tables can link straight
409    // to the first matching drill-down row:
410    //   `miss-cat-<category>` on the <tr>
411    //   `miss-op-<slug>`      on a zero-size <span> in the first cell
412    // (a <tr> can only carry one `id`, so the per-op anchor rides on
413    // the span; HTML treats both as valid jump targets). First-seen
414    // wins to avoid duplicate IDs across the truncated table.
415    let mut seen_cat: std::collections::HashSet<String> = std::collections::HashSet::new();
416    let mut seen_op: std::collections::HashSet<String> = std::collections::HashSet::new();
417    for (op, neg) in missed.iter().take(take) {
418        let cat = neg.label.split(':').next().unwrap_or("other").to_string();
419        let op_slug = op_anchor_slug(&op.method, &op.path);
420        let tr_id = if seen_cat.insert(cat.clone()) {
421            format!(" id=\"miss-cat-{}\"", html_escape(&cat))
422        } else {
423            String::new()
424        };
425        let op_anchor = if seen_op.insert(op_slug.clone()) {
426            format!("<span id=\"miss-op-{op_slug}\"></span>")
427        } else {
428            String::new()
429        };
430        out.push_str(&format!(
431            "<tr{}><td>{}<code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td><td><span class=\"badge info\">{}</span></td><td>{}</td></tr>\n",
432            tr_id,
433            op_anchor,
434            html_escape(&op.method),
435            html_escape(&op.path),
436            html_escape(&neg.label),
437            expected_status_label(neg),
438            neg.actual_status
439        ));
440    }
441    out.push_str("</tbody></table>\n");
442}
443
444fn push_spec_audit(out: &mut String, audit: &serde_json::Value) {
445    out.push_str("<h2>Spec audit</h2>\n");
446    let findings = audit.get("findings").and_then(|v| v.as_array());
447    let coverage = audit.get("datatype_coverage").and_then(|v| v.as_object());
448    let ops = audit.get("operations_audited").and_then(|v| v.as_u64()).unwrap_or(0);
449    out.push_str(&format!(
450        "<p class=\"small\">Audited {ops} operation(s). Coverage map: {} datatype kind(s).</p>\n",
451        coverage.map(|c| c.len()).unwrap_or(0)
452    ));
453    if let Some(findings) = findings {
454        if findings.is_empty() {
455            out.push_str("<p class=\"small\">No findings.</p>\n");
456        } else {
457            // Group findings by severity for an easy scan.
458            let mut by_sev: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
459            for f in findings {
460                let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
461                by_sev.entry(sev).or_default().push(f);
462            }
463            out.push_str("<table>\n<thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Message</th></tr></thead>\n<tbody>\n");
464            for (sev, items) in by_sev {
465                let badge_class = match sev.as_str() {
466                    "error" => "err",
467                    "warning" => "warn",
468                    _ => "info",
469                };
470                for item in items {
471                    let cat = item.get("category").and_then(|v| v.as_str()).unwrap_or("");
472                    let loc = item.get("location").and_then(|v| v.as_str()).unwrap_or("");
473                    let msg = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
474                    out.push_str(&format!(
475                        "<tr><td><span class=\"badge {}\">{}</span></td><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>\n",
476                        badge_class,
477                        html_escape(&sev),
478                        html_escape(cat),
479                        html_escape(loc),
480                        html_escape(msg)
481                    ));
482                }
483            }
484            out.push_str("</tbody></table>\n");
485        }
486    }
487    if let Some(coverage) = coverage {
488        let mut entries: Vec<(&String, u64)> =
489            coverage.iter().filter_map(|(k, v)| v.as_u64().map(|c| (k, c))).collect();
490        entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
491        if !entries.is_empty() {
492            out.push_str("<h2>Datatype coverage</h2>\n");
493            out.push_str("<table>\n<thead><tr><th>Type</th><th>Count</th></tr></thead>\n<tbody>\n");
494            for (kind, count) in entries.iter().take(40) {
495                out.push_str(&format!(
496                    "<tr><td><code>{}</code></td><td>{}</td></tr>\n",
497                    html_escape(kind),
498                    count
499                ));
500            }
501            out.push_str("</tbody></table>\n");
502        }
503    }
504}
505
506fn html_escape(s: &str) -> String {
507    let mut out = String::with_capacity(s.len());
508    for c in s.chars() {
509        match c {
510            '&' => out.push_str("&amp;"),
511            '<' => out.push_str("&lt;"),
512            '>' => out.push_str("&gt;"),
513            '"' => out.push_str("&quot;"),
514            '\'' => out.push_str("&#39;"),
515            _ => out.push(c),
516        }
517    }
518    out
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::conformance::self_test::{CaseOutcome, OperationResult, SelfTestReport};
525
526    fn sample_report() -> SelfTestReport {
527        SelfTestReport {
528            positive_pass: 3,
529            positive_fail: 1,
530            negative_caught: BTreeMap::from([("request-body".into(), 4), ("parameters".into(), 2)]),
531            negative_missed: BTreeMap::from([("owasp".into(), 1)]),
532            operations: vec![OperationResult {
533                method: "POST".into(),
534                path: "/users".into(),
535                positive: Some(CaseOutcome {
536                    label: "positive".into(),
537                    expected_4xx: false,
538                    actual_status: 201,
539                    passed: true,
540                }),
541                negatives: vec![CaseOutcome {
542                    label: "owasp:sqli".into(),
543                    expected_4xx: true,
544                    actual_status: 200,
545                    passed: false,
546                }],
547            }],
548        }
549    }
550
551    #[test]
552    fn html_contains_expected_sections() {
553        let html = render_html(&sample_report(), None);
554        assert!(html.contains("<title>MockForge Conformance Report</title>"));
555        assert!(html.contains("Positive cases"));
556        assert!(html.contains("Negatives by category"));
557        assert!(html.contains("Per-operation results"));
558        // Round 23 wording polish: "Missed negatives" → "Mismatched negatives".
559        assert!(html.contains("Mismatched negatives"));
560        // Specific data points from the sample report:
561        assert!(html.contains("request-body"));
562        assert!(html.contains("owasp:sqli"));
563        assert!(html.contains("/users"));
564        // Round 26 — single grouped category table, no separate family
565        // section, but a Family column inside the per-category table.
566        assert!(!html.contains("Negatives by category family"));
567        assert!(html.contains("<th>Family</th>"));
568    }
569
570    /// Round 26 — every probe category in the report shows up under
571    /// its mapped family in the per-category table. Catches the case
572    /// where a new probe family (e.g. round 25's content-type-mismatch)
573    /// silently slips through with "other" instead of "Request body".
574    #[test]
575    fn html_category_table_assigns_each_category_to_a_family() {
576        let mut report = SelfTestReport::default();
577        report.negative_caught.insert("request-body".into(), 3);
578        report.negative_missed.insert("parameters".into(), 1);
579        report.negative_missed.insert("security".into(), 2);
580        report.negative_caught.insert("owasp".into(), 4);
581        let html = render_html(&report, None);
582        assert!(html.contains(">Request body</td>"));
583        assert!(html.contains(">Parameters</td>"));
584        // Both `security` and `owasp` get the same family label.
585        assert_eq!(html.matches(">Security</td>").count(), 2);
586        // No category should fall through to the "other" bucket here.
587        assert!(!html.contains(">other</td>"));
588    }
589
590    #[test]
591    fn html_renders_audit_section_when_present() {
592        let audit = serde_json::json!({
593            "findings": [
594                {"category": "servers", "severity": "warning",
595                 "location": "#/servers", "message": "no servers declared"}
596            ],
597            "datatype_coverage": {"string": 5, "integer": 3},
598            "operations_audited": 7
599        });
600        let html = render_html(&sample_report(), Some(&audit));
601        assert!(html.contains("Spec audit"));
602        assert!(html.contains("no servers declared"));
603        assert!(html.contains("Datatype coverage"));
604        assert!(html.contains("string"));
605        assert!(html.contains("Audited 7 operation"));
606    }
607
608    #[test]
609    fn html_escapes_special_chars_in_labels() {
610        let mut report = sample_report();
611        report.operations[0].path = "/items/<script>".into();
612        report.operations[0].negatives[0].label = "owasp:xss:<>\"&".into();
613        let html = render_html(&report, None);
614        // The literal special chars should be escaped, not rendered raw.
615        assert!(!html.contains("/items/<script>"));
616        assert!(html.contains("&lt;script&gt;"));
617        assert!(html.contains("&quot;"));
618    }
619
620    #[test]
621    fn html_handles_empty_report() {
622        let html = render_html(&SelfTestReport::default(), None);
623        assert!(html.contains("No negative probes ran"));
624        assert!(html.contains("No operations."));
625    }
626
627    #[test]
628    fn html_caps_missed_detail_at_default_200_rows() {
629        let mut report = SelfTestReport::default();
630        for i in 0..250 {
631            report.operations.push(OperationResult {
632                method: "GET".into(),
633                path: format!("/r/{i}"),
634                positive: None,
635                negatives: vec![CaseOutcome {
636                    label: "parameters:missing-query".into(),
637                    expected_4xx: true,
638                    actual_status: 200,
639                    passed: false,
640                }],
641            });
642        }
643        report.negative_missed.insert("parameters".into(), 250);
644        let html = render_html(&report, None);
645        // Cap message visible and references the new flag (round 23: "missed" → "mismatched"):
646        assert!(html.contains("250 mismatched negative"));
647        assert!(html.contains("Showing first 200"));
648        assert!(html.contains("--report-missed-cap"));
649    }
650
651    /// Round 21.1 — when `missed_cap` is `None` (set via
652    /// `--report-missed-cap 0`), all rows are shown and the message
653    /// says so explicitly.
654    #[test]
655    fn html_no_cap_shows_all_rows() {
656        let mut report = SelfTestReport::default();
657        for i in 0..50 {
658            report.operations.push(OperationResult {
659                method: "GET".into(),
660                path: format!("/r/{i}"),
661                positive: None,
662                negatives: vec![CaseOutcome {
663                    label: "parameters:missing-query".into(),
664                    expected_4xx: true,
665                    actual_status: 200,
666                    passed: false,
667                }],
668            });
669        }
670        let opts = RenderOptions { missed_cap: None };
671        let html = render_html_with_options(&report, None, &opts);
672        assert!(html.contains("50 mismatched negative"));
673        assert!(html.contains("All shown (no cap)"));
674        assert!(!html.contains("Showing first"));
675    }
676
677    /// Round 21.1 — the missed-negative table now has an Expected
678    /// column derived from the probe's `expected_4xx` flag.
679    #[test]
680    fn html_missed_table_has_expected_column() {
681        let mut report = sample_report();
682        // The sample's single missed negative is `owasp:sqli` with
683        // expected_4xx: true.
684        report.operations[0].negatives = vec![CaseOutcome {
685            label: "security:bad-bearer".into(),
686            expected_4xx: true,
687            actual_status: 200,
688            passed: false,
689        }];
690        let html = render_html(&report, None);
691        assert!(html.contains("Expected"), "Expected column header missing");
692        assert!(
693            html.contains("4xx (reject)"),
694            "expected-status badge for negative probe missing"
695        );
696    }
697
698    /// Round 24 (e) — when `--report-missed-cap` truncates the drill-
699    /// down to N rows, the count-cells in the upper tables must only
700    /// link to a `#miss-cat-*` or `#miss-op-*` anchor that's actually
701    /// rendered. Srikanth's report: clicking a count past the cap
702    /// dead-ended because the anchor was cropped out.
703    #[test]
704    fn html_count_links_only_emit_for_visible_anchors() {
705        let mut report = SelfTestReport::default();
706        // 4 operations, each contributes one mismatched negative.
707        // Categories alternate `cat-a` and `cat-b` so we have two
708        // distinct categories. With cap=1 only the first row survives
709        // the truncation, so its category/operation should be the
710        // only ones linked from the upper tables.
711        let cats = ["cat-a", "cat-b", "cat-a", "cat-b"];
712        for (i, c) in cats.iter().enumerate() {
713            report.operations.push(OperationResult {
714                method: "GET".into(),
715                path: format!("/r/{i}"),
716                positive: None,
717                negatives: vec![CaseOutcome {
718                    label: format!("{c}:fail-{i}"),
719                    expected_4xx: true,
720                    actual_status: 200,
721                    passed: false,
722                }],
723            });
724            *report.negative_missed.entry((*c).to_string()).or_insert(0) += 1;
725        }
726        let opts = RenderOptions {
727            missed_cap: Some(1),
728        };
729        let html = render_html_with_options(&report, None, &opts);
730        // The drill-down only renders one row; its category is
731        // `cat-a` and its operation slug is `get__r_0`.
732        assert!(html.contains("id=\"miss-cat-cat-a\""));
733        assert!(!html.contains("id=\"miss-cat-cat-b\""));
734        // cat-a's count cell is a link; cat-b's count cell is just
735        // the number with no `<a href="#miss-cat-cat-b">`.
736        assert!(html.contains("<a href=\"#miss-cat-cat-a\">"));
737        assert!(!html.contains("<a href=\"#miss-cat-cat-b\">"));
738        // Per-op: only `/r/0` has an anchor in the drill-down, so
739        // only its count is a link.
740        assert!(html.contains("<a href=\"#miss-op-get__r_0\">"));
741        assert!(!html.contains("<a href=\"#miss-op-get__r_2\">"));
742    }
743}