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