Skip to main content

mockforge_bench/conformance/
capture_html.rs

1//! Browser-viewable HTML rendering of self-test request/response capture.
2//!
3//! Issue #79 round 24 (Srikanth (d) follow-up). `conformance-self-test-
4//! requests.jsonl` is great for `jq` and `grep`, but Srikanth asked for
5//! something that can be loaded in a browser without external tooling.
6//! This module renders the same `CaseCapture` records into a single
7//! self-contained HTML file (no external CSS / JS / images).
8//!
9//! Round 27 (Srikanth d3) — replaced the prior content-visibility +
10//! 1000-card cap approach with proper pagination. The 1000-card cap
11//! silently hid the 4xx/5xx probes Srikanth needed to investigate
12//! because they sat past the cap. The viewer now embeds the full
13//! capture as a JSON array (`window.__captures`), filters that array
14//! in JS, then renders only the current page (50 cards) of the
15//! filtered subset. Filters span ALL probes regardless of which page
16//! is visible, so paging never hides matching probes.
17
18use super::self_test::CaseCapture;
19
20/// Round 27 — page size for the paginated capture viewer. 50 cards
21/// per page keeps initial render under 100 ms on a modern browser
22/// while still showing enough context to scan a category at a glance.
23/// The full dataset is held in memory as JSON; only the slice is in
24/// the DOM at any time. Mirrored as `PAGE_SIZE` in the JS handler
25/// below; the test `pagination_controls_present` asserts the two
26/// stay in sync.
27#[allow(dead_code)] // documentation/contract for the JS-side constant
28const PAGE_SIZE: usize = 50;
29
30/// Render the full HTML viewer for a slice of captured probes.
31/// Bodies are kept verbatim (already truncated upstream to
32/// `CAPTURE_BODY_CAP_BYTES`). The whole capture is serialised as a
33/// JSON array embedded in a `<script>` tag and rendered on demand by
34/// the inline JS handler; cross-page filters work over the full
35/// array, not the visible page.
36pub fn render_capture_html(entries: &[CaseCapture]) -> String {
37    let total = entries.len();
38    let mut out = String::with_capacity(total.max(1) * 1024);
39    out.push_str(HEAD);
40    push_summary(&mut out, entries);
41    out.push_str("<div id=\"cards\"></div>\n");
42    push_pagination_controls(&mut out);
43    push_data_script(&mut out, entries);
44    out.push_str(FOOT);
45    out
46}
47
48fn push_summary(out: &mut String, entries: &[CaseCapture]) {
49    let total = entries.len();
50    // Round 36 (#875) — count pass/fail by the probe's expected range
51    // rather than the raw HTTP status, so variant-b probes with
52    // expected_status_range="2xx-4xx" count their 400s as passes.
53    // Falls back to raw 200..400 when no expected_status_range was
54    // recorded (legacy JSONL).
55    let pass = entries.iter().filter(|e| probe_passed(e)).count();
56    let fail = total - pass;
57    out.push_str(&format!(
58        "<header>\n\
59         <h1>Self-Test Request/Response Capture</h1>\n\
60         <p class=\"meta\">{total} probe(s); {pass} matched the expected range, {fail} did not or errored. \
61         Generated by <code>mockforge bench --conformance-self-test --conformance-self-test-capture</code>.</p>\n\
62         <div class=\"toolbar\">\n\
63         <input type=\"search\" id=\"q\" placeholder=\"filter by label, method, URL, or status\" oninput=\"scheduleFilter()\" />\n\
64         <label><input type=\"checkbox\" id=\"showPass\" checked onchange=\"applyFilter()\"/> matched expected</label>\n\
65         <label><input type=\"checkbox\" id=\"showFail\" checked onchange=\"applyFilter()\"/> mismatch</label>\n\
66         <label><input type=\"checkbox\" id=\"showErr\" checked onchange=\"applyFilter()\"/> transport error</label>\n\
67         <label><input type=\"checkbox\" id=\"onlyMismatches\" onchange=\"applyFilter()\"/> only show mismatches</label>\n\
68         <span id=\"filterStatus\" class=\"small\"></span>\n\
69         </div>\n\
70         </header>\n"
71    ));
72}
73
74/// Round 36 (#875) — mirror of the JS `isMismatch` / `statusClass`
75/// "pass" branch in Rust so the top-of-page summary counts probes
76/// the same way the per-card badge colours them. Returns true when
77/// the capture's actual response status is within whatever
78/// `expected_status_range` the probe declared; falls back to the
79/// 200..400 convention for legacy captures.
80fn probe_passed(c: &CaseCapture) -> bool {
81    if c.error.is_some() {
82        return false;
83    }
84    let s = c.response_status;
85    match c.expected_status_range.as_str() {
86        "4xx" => (400..500).contains(&s),
87        "2xx-3xx" => (200..400).contains(&s),
88        "2xx-4xx" => (200..500).contains(&s),
89        // Unknown / empty: fall back to round-23 raw-range behaviour.
90        _ => (200..400).contains(&s),
91    }
92}
93
94fn push_pagination_controls(out: &mut String) {
95    out.push_str(
96        "<div class=\"pager\">\n\
97         <button id=\"firstPage\" onclick=\"gotoPage(0)\">First</button>\n\
98         <button id=\"prevPage\" onclick=\"gotoPage(currentPage - 1)\">Prev</button>\n\
99         <span id=\"pageNum\" class=\"pageNum\"></span>\n\
100         <button id=\"nextPage\" onclick=\"gotoPage(currentPage + 1)\">Next</button>\n\
101         <button id=\"lastPage\" onclick=\"gotoPage(totalPages - 1)\">Last</button>\n\
102         <label class=\"small\">Jump to page: <input type=\"number\" id=\"jumpPage\" min=\"1\" style=\"width: 5em\" onchange=\"jumpToPage()\" /></label>\n\
103         </div>\n",
104    );
105}
106
107/// Embed the full capture as a JSON array assigned to
108/// `window.__captures`. JSON.stringify with default escaping is
109/// XSS-safe inside a `<script>` tag as long as we don't have a
110/// closing `</script>` substring. `serde_json::to_string` does NOT
111/// escape `<`, so we post-process to break any literal `</` so the
112/// script element can't be terminated by the payload. The browser
113/// JS handler unescapes this back transparently.
114fn push_data_script(out: &mut String, entries: &[CaseCapture]) {
115    out.push_str("<script id=\"captureData\" type=\"application/json\">\n");
116    // Serialise to a single line for compactness. If serialisation
117    // somehow fails (shouldn't, the struct is all Serialize), embed
118    // an empty array so the viewer still loads.
119    let json = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string());
120    // Defensive: protect against payload bodies containing
121    // `</script>`. The JSON `<` becomes `<` which is valid JSON
122    // and the JS parser accepts it.
123    let safe = json.replace("</", r"<\/");
124    out.push_str(&safe);
125    out.push_str("\n</script>\n");
126}
127
128const HEAD: &str = r#"<!doctype html>
129<html lang="en">
130<head>
131<meta charset="utf-8">
132<title>MockForge Self-Test Capture</title>
133<style>
134  body { font-family: -apple-system, system-ui, sans-serif; max-width: 1200px;
135         margin: 1rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.45; }
136  h1 { font-size: 1.6rem; margin: 0; }
137  h3 { margin: 1rem 0 0.3rem; font-size: 0.95rem; color: #374151; }
138  header { border-bottom: 1px solid #e5e7eb; padding-bottom: 1rem; margin-bottom: 1rem; }
139  .meta { color: #6b7280; font-size: 0.9rem; margin: 0.25rem 0 0.75rem; }
140  .toolbar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
141  .toolbar input[type=search] { flex: 1; min-width: 240px; padding: 0.4rem 0.6rem;
142    border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.9rem; }
143  .toolbar label { font-size: 0.85rem; color: #4b5563; cursor: pointer; }
144  .pager { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;
145    margin: 0.75rem 0; padding: 0.5rem 0; border-top: 1px solid #e5e7eb;
146    border-bottom: 1px solid #e5e7eb; }
147  .pager button { padding: 0.25rem 0.75rem; font-size: 0.85rem;
148    border: 1px solid #d1d5db; background: #fff; border-radius: 4px; cursor: pointer; }
149  .pager button:hover:not(:disabled) { background: #f3f4f6; }
150  .pager button:disabled { opacity: 0.4; cursor: not-allowed; }
151  .pager .pageNum { font-size: 0.85rem; color: #4b5563; min-width: 6em;
152    text-align: center; font-variant-numeric: tabular-nums; }
153  details.card { border: 1px solid #e5e7eb; border-radius: 6px; margin: 0.4rem 0;
154    background: #fff; }
155  details.card[open] { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
156  summary { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; gap: 0.5rem;
157    align-items: center; flex-wrap: wrap; }
158  summary::-webkit-details-marker { color: #9ca3af; }
159  .method { background: #f3f4f6; padding: 0.05rem 0.4rem; border-radius: 3px;
160    font-size: 0.8rem; }
161  .label { color: #374151; font-size: 0.85rem; }
162  .url { color: #6b7280; font-size: 0.8rem; word-break: break-all; }
163  .body { padding: 0 0.75rem 0.75rem; border-top: 1px solid #f3f4f6; }
164  .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px;
165    font-size: 0.75rem; font-weight: 600; }
166  .badge.pass { background: #d1fae5; color: #047857; }
167  .badge.fail { background: #fee2e2; color: #b91c1c; }
168  .badge.err  { background: #fef3c7; color: #92400e; }
169  .badge.info { background: #dbeafe; color: #1d4ed8; }
170  table.kv { width: 100%; font-size: 0.85rem; border-collapse: collapse;
171    margin: 0.25rem 0 0.75rem; }
172  table.kv td { padding: 0.2rem 0.5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
173  table.kv td:first-child { color: #4b5563; width: 30%; max-width: 280px; }
174  pre { background: #f9fafb; border: 1px solid #f3f4f6; padding: 0.5rem;
175    border-radius: 4px; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap;
176    word-break: break-word; max-height: 320px; overflow-y: auto; }
177  pre.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
178  .small { color: #6b7280; font-size: 0.75rem; }
179  code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em; }
180</style>
181</head>
182<body>
183"#;
184
185const FOOT: &str = r#"
186<script>
187// Round 27 — pagination + cross-page filter over the JSON-embedded
188// full capture. The previous CSS-only show/hide approach silently
189// dropped 4xx/5xx probes past the 1000-card cap (Srikanth flagged
190// this on 0.3.169). Now the JS holds the full capture in memory,
191// filters across the whole array on every input, and only renders
192// the current page (PAGE_SIZE entries) of the filtered subset.
193
194const PAGE_SIZE = 50;
195let captures = [];
196try {
197  const raw = document.getElementById('captureData').textContent.trim();
198  captures = raw ? JSON.parse(raw) : [];
199} catch (e) {
200  document.getElementById('cards').innerHTML =
201    '<p class="small">Failed to load capture data: ' + e.message + '</p>';
202}
203let filtered = captures.slice();
204let currentPage = 0;
205let totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
206
207function escapeHtml(s) {
208  if (s == null) return '';
209  return String(s)
210    .replace(/&/g, '&amp;')
211    .replace(/</g, '&lt;')
212    .replace(/>/g, '&gt;')
213    .replace(/"/g, '&quot;')
214    .replace(/'/g, '&#39;');
215}
216
217function statusClass(c) {
218  if (c.error) return 'err';
219  // Round 36 (#875) — Srikanth on 0.3.180: variant-b embedded-content
220  // probes (expected_status_range="2xx-4xx") were drawing their 400
221  // status badges in red even though the probe passed, because the
222  // raw 200..400 range fell through to "fail". Drive the colour
223  // through isMismatch when the capture knows its expected range, so
224  // a 400 with expected_status_range="2xx-4xx" reads as green. Older
225  // captures without expected_status_range keep the round-23 raw-
226  // range behaviour for backwards compatibility.
227  if (c.expected_status_range) {
228    return isMismatch(c) ? 'fail' : 'pass';
229  }
230  if (c.response_status >= 200 && c.response_status < 400) return 'pass';
231  if (c.response_status >= 400 && c.response_status < 600) return 'fail';
232  return 'info';
233}
234
235function renderKv(title, kv) {
236  const keys = kv ? Object.keys(kv).sort() : [];
237  if (keys.length === 0) {
238    return '<h3>' + escapeHtml(title) + '</h3><p class="small">(none)</p>';
239  }
240  let html = '<h3>' + escapeHtml(title) + '</h3><table class="kv"><tbody>';
241  for (const k of keys) {
242    html += '<tr><td><code>' + escapeHtml(k) + '</code></td><td><code>' +
243            escapeHtml(kv[k]) + '</code></td></tr>';
244  }
245  html += '</tbody></table>';
246  return html;
247}
248
249function renderBody(title, body, truncated) {
250  if (body == null) return '';
251  const suffix = truncated ? ' <span class="small">(truncated at 16 KiB)</span>' : '';
252  return '<h3>' + escapeHtml(title) + suffix + '</h3><pre>' + escapeHtml(body) + '</pre>';
253}
254
255// Round 28 (Srikanth) — true when the probe's actual status didn't
256// match its expected range. Used by the "only show mismatches" filter
257// AND by the summary badge so users can spot misses at a glance.
258function isMismatch(c) {
259  const expected = c.expected_status_range || '';
260  const s = c.response_status;
261  if (c.error) return true;
262  if (expected === '4xx') return !(s >= 400 && s < 500);
263  if (expected === '2xx-3xx') return !(s >= 200 && s < 400);
264  return false;
265}
266
267function renderCard(c) {
268  const cls = statusClass(c);
269  const statusText = c.error ? 'ERR' : String(c.response_status);
270  let html = '<details class="card">';
271  html += '<summary>';
272  html += '<span class="badge ' + cls + '">' + escapeHtml(statusText) + '</span> ';
273  // Round 28 — show the expected range alongside the actual status so
274  // a reader knows what the probe wanted to see without expanding the
275  // card.
276  if (c.expected_status_range) {
277    const matchCls = isMismatch(c) ? 'fail' : 'pass';
278    html += '<span class="badge ' + matchCls + '" title="expected status range">exp ' +
279            escapeHtml(c.expected_status_range) + '</span> ';
280  }
281  html += '<code class="method">' + escapeHtml(c.method) + '</code> ';
282  html += '<span class="label">' + escapeHtml(c.label) + '</span> ';
283  html += '<code class="url">' + escapeHtml(c.url) + '</code>';
284  html += '</summary><div class="body">';
285  // Round 36 (#876) — surface the client stamps in a dedicated row so
286  // a reader can spot them without expanding the request-headers
287  // section. Older captures without these fields skip the row.
288  if (c.mockforge_version || c.client_sent_at) {
289    html += '<div class="stamps"><strong>Client:</strong> ';
290    if (c.mockforge_version) {
291      html += 'mockforge ' + escapeHtml(c.mockforge_version);
292    }
293    if (c.mockforge_version && c.client_sent_at) {
294      html += ' &middot; ';
295    }
296    if (c.client_sent_at) {
297      html += 'sent ' + escapeHtml(c.client_sent_at);
298    }
299    html += '</div>';
300  }
301  html += renderKv('Request headers', c.request_headers);
302  html += renderBody('Request body', c.request_body, c.request_body_truncated);
303  html += renderKv('Response headers', c.response_headers);
304  html += renderBody('Response body', c.response_body, c.response_body_truncated);
305  if (c.error) {
306    html += '<h3>Transport error</h3><pre class="err">' + escapeHtml(c.error) + '</pre>';
307  }
308  if (c.response_schema_error) {
309    html += '<h3>Response schema mismatch</h3><pre class="err">' +
310            escapeHtml(c.response_schema_error) + '</pre>';
311  }
312  html += '</div></details>';
313  return html;
314}
315
316function renderPage() {
317  totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
318  if (currentPage >= totalPages) currentPage = totalPages - 1;
319  if (currentPage < 0) currentPage = 0;
320  const start = currentPage * PAGE_SIZE;
321  const end = Math.min(start + PAGE_SIZE, filtered.length);
322  const slice = filtered.slice(start, end);
323  document.getElementById('cards').innerHTML = slice.map(renderCard).join('');
324  document.getElementById('pageNum').textContent =
325    'Page ' + (currentPage + 1) + ' / ' + totalPages +
326    ' (' + (filtered.length === 0 ? 0 : (start + 1)) + '-' + end +
327    ' of ' + filtered.length + ' filtered)';
328  document.getElementById('firstPage').disabled = currentPage === 0;
329  document.getElementById('prevPage').disabled = currentPage === 0;
330  document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
331  document.getElementById('lastPage').disabled = currentPage >= totalPages - 1;
332  document.getElementById('filterStatus').textContent =
333    filtered.length === captures.length ? '' :
334    '(' + filtered.length + ' of ' + captures.length + ' shown)';
335  document.getElementById('jumpPage').max = totalPages;
336  document.getElementById('jumpPage').value = currentPage + 1;
337  window.scrollTo({ top: 0, behavior: 'auto' });
338}
339
340function gotoPage(p) {
341  if (p < 0 || p >= totalPages) return;
342  currentPage = p;
343  renderPage();
344}
345
346function jumpToPage() {
347  const v = parseInt(document.getElementById('jumpPage').value, 10);
348  if (!isNaN(v)) gotoPage(v - 1);
349}
350
351let _filterTimer = null;
352function scheduleFilter() {
353  if (_filterTimer) clearTimeout(_filterTimer);
354  _filterTimer = setTimeout(applyFilter, 200);
355}
356
357function applyFilter() {
358  const q = document.getElementById('q').value.trim().toLowerCase();
359  const showPass = document.getElementById('showPass').checked;
360  const showFail = document.getElementById('showFail').checked;
361  const showErr = document.getElementById('showErr').checked;
362  const onlyMismatches = document.getElementById('onlyMismatches').checked;
363  filtered = captures.filter(function(c) {
364    // Round 28 — the mismatch filter runs FIRST so it composes
365    // naturally with the status checkboxes (e.g. "mismatches that
366    // are 4xx-5xx").
367    if (onlyMismatches && !isMismatch(c)) return false;
368    const cls = statusClass(c);
369    const statusOk = (cls === 'pass' && showPass) ||
370                     (cls === 'fail' && showFail) ||
371                     (cls === 'err' && showErr) ||
372                     (cls === 'info' && (showPass || showFail));
373    if (!statusOk) return false;
374    if (!q) return true;
375    const hay = ((c.label || '') + ' ' + (c.method || '') + ' ' +
376                 (c.url || '') + ' ' + (c.response_status || '')).toLowerCase();
377    return hay.indexOf(q) !== -1;
378  });
379  currentPage = 0;
380  renderPage();
381}
382
383// Initial render once the page loads.
384renderPage();
385</script>
386</body>
387</html>
388"#;
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::collections::BTreeMap;
394
395    fn sample() -> Vec<CaseCapture> {
396        let mut req_h = BTreeMap::new();
397        req_h.insert("X-Forwarded-For".to_string(), "203.0.113.0".to_string());
398        let mut resp_h = BTreeMap::new();
399        resp_h.insert("content-type".to_string(), "application/json".to_string());
400        vec![
401            CaseCapture {
402                label: "positive".to_string(),
403                method: "GET".to_string(),
404                url: "http://target/users".to_string(),
405                request_headers: req_h.clone(),
406                request_body: None,
407                request_body_truncated: false,
408                response_status: 200,
409                response_headers: resp_h.clone(),
410                response_body: Some("{\"ok\":true}".to_string()),
411                response_body_truncated: false,
412                error: None,
413                response_schema_error: None,
414                expected_status_range: "2xx-3xx".to_string(),
415                path_template: String::new(),
416                spec_label: None,
417                mockforge_version: String::new(),
418                client_sent_at: String::new(),
419            },
420            CaseCapture {
421                label: "owasp:sqli".to_string(),
422                method: "GET".to_string(),
423                url: "http://target/users?id=' OR 1=1".to_string(),
424                request_headers: BTreeMap::new(),
425                request_body: None,
426                request_body_truncated: false,
427                response_status: 500,
428                response_headers: resp_h,
429                response_body: Some("[]".to_string()),
430                response_body_truncated: false,
431                error: None,
432                response_schema_error: None,
433                expected_status_range: "2xx-3xx".to_string(),
434                path_template: String::new(),
435                spec_label: None,
436                mockforge_version: String::new(),
437                client_sent_at: String::new(),
438            },
439        ]
440    }
441
442    #[test]
443    fn embeds_all_probes_as_json() {
444        let html = render_capture_html(&sample());
445        // The JSON data script holds the full capture.
446        assert!(html.contains("id=\"captureData\""));
447        // Both labels are present in the JSON payload.
448        assert!(html.contains("\"positive\""));
449        assert!(html.contains("\"owasp:sqli\""));
450    }
451
452    /// Round 27 — Srikanth's d3 follow-up was that the round-25
453    /// 1000-card cap hid 4xx/5xx probes past the cap. The fix is
454    /// pagination over the full capture. A capture WAY past the
455    /// old cap must still appear in the embedded data.
456    #[test]
457    fn no_silent_cap_past_one_thousand() {
458        let mut entries = Vec::with_capacity(1500);
459        for i in 0..1500 {
460            entries.push(CaseCapture {
461                label: format!("probe-{i}"),
462                method: "GET".into(),
463                url: format!("/path/{i}"),
464                request_headers: BTreeMap::new(),
465                request_body: None,
466                request_body_truncated: false,
467                response_status: if i % 4 == 0 { 500 } else { 200 },
468                response_headers: BTreeMap::new(),
469                response_body: None,
470                response_body_truncated: false,
471                error: None,
472                response_schema_error: None,
473                expected_status_range: "2xx-3xx".to_string(),
474                path_template: String::new(),
475                spec_label: None,
476                mockforge_version: String::new(),
477                client_sent_at: String::new(),
478            });
479        }
480        let html = render_capture_html(&entries);
481        // Probe past the old 1000 cap is still embedded.
482        assert!(html.contains("\"probe-1234\""));
483        assert!(html.contains("\"probe-1499\""));
484        // Per-card cap text is gone (we paginate instead).
485        assert!(!html.contains("Showing first 1000 of"));
486    }
487
488    #[test]
489    fn pagination_controls_present() {
490        let html = render_capture_html(&sample());
491        for id in [
492            "firstPage",
493            "prevPage",
494            "nextPage",
495            "lastPage",
496            "jumpPage",
497            "pageNum",
498        ] {
499            assert!(html.contains(id), "missing pagination control: {id}");
500        }
501        assert!(html.contains("PAGE_SIZE = 50"));
502    }
503
504    #[test]
505    fn empty_capture_still_produces_valid_html() {
506        let html = render_capture_html(&[]);
507        assert!(html.starts_with("<!doctype html>"));
508        assert!(html.contains("0 probe(s)"));
509        assert!(html.contains("</html>"));
510    }
511
512    fn cap_with_range(status: u16, expected_range: &str) -> CaseCapture {
513        CaseCapture {
514            label: "x".to_string(),
515            method: "POST".to_string(),
516            url: "http://t/x".to_string(),
517            request_headers: BTreeMap::new(),
518            request_body: None,
519            request_body_truncated: false,
520            response_status: status,
521            response_headers: BTreeMap::new(),
522            response_body: None,
523            response_body_truncated: false,
524            error: None,
525            response_schema_error: None,
526            expected_status_range: expected_range.to_string(),
527            path_template: String::new(),
528            spec_label: None,
529            mockforge_version: String::new(),
530            client_sent_at: String::new(),
531        }
532    }
533
534    /// Round 36 (#875) — `probe_passed` must agree with the JS
535    /// `isMismatch` / `statusClass` "pass" branch the per-card
536    /// badge already uses, so the top-of-page summary's pass/fail
537    /// tally is consistent with the green/red badges below it.
538    #[test]
539    fn probe_passed_honours_expected_status_range() {
540        // Round 35 (#859): variant-b expects 2xx-4xx; a 400 must pass.
541        assert!(
542            probe_passed(&cap_with_range(400, "2xx-4xx")),
543            "Srikanth's local-accounts 400 case: variant-b probe expecting 2xx-4xx must pass on 400"
544        );
545        assert!(probe_passed(&cap_with_range(204, "2xx-4xx")));
546        assert!(probe_passed(&cap_with_range(415, "2xx-4xx")));
547        assert!(
548            !probe_passed(&cap_with_range(500, "2xx-4xx")),
549            "5xx is the only failure mode for variant-b"
550        );
551
552        // Negative probes expect 4xx; 200 is a miss.
553        assert!(probe_passed(&cap_with_range(404, "4xx")));
554        assert!(probe_passed(&cap_with_range(422, "4xx")));
555        assert!(!probe_passed(&cap_with_range(200, "4xx")));
556        assert!(!probe_passed(&cap_with_range(500, "4xx")));
557
558        // Positive probes expect 2xx-3xx; 400 fails as before.
559        assert!(probe_passed(&cap_with_range(200, "2xx-3xx")));
560        assert!(probe_passed(&cap_with_range(301, "2xx-3xx")));
561        assert!(!probe_passed(&cap_with_range(400, "2xx-3xx")));
562
563        // Legacy captures without a range fall back to 2xx-3xx semantics.
564        assert!(probe_passed(&cap_with_range(200, "")));
565        assert!(!probe_passed(&cap_with_range(400, "")));
566
567        // Transport errors always fail regardless of expected range.
568        let mut err = cap_with_range(0, "2xx-4xx");
569        err.error = Some("connection refused".to_string());
570        assert!(!probe_passed(&err));
571    }
572
573    /// Round 36 (#875) — Srikanth's screenshot of v0.3.180 showed the
574    /// summary line counting variant-b 400s as failures. The header
575    /// copy must reflect the new "matched expected" semantics so the
576    /// pass count agrees with the green per-card badges.
577    #[test]
578    fn summary_counts_variant_b_400_as_pass() {
579        let entries = vec![
580            cap_with_range(400, "2xx-4xx"),
581            cap_with_range(400, "2xx-4xx"),
582            cap_with_range(500, "2xx-4xx"),
583            cap_with_range(200, "2xx-3xx"),
584        ];
585        let html = render_capture_html(&entries);
586        // The header copy reads "X matched the expected range, Y did not or errored".
587        // Three of the four captures pass; only the 500 fails.
588        assert!(
589            html.contains("3 matched the expected range, 1 did not"),
590            "summary line should count the two 400 / 2xx-4xx rows as passes; got:\n{}",
591            html.lines().find(|l| l.contains("matched")).unwrap_or("<no match line>")
592        );
593    }
594
595    #[test]
596    fn embedded_json_breaks_inline_script_terminators() {
597        // A payload body containing `</script>` could break out of
598        // the inline data <script> if we didn't escape it. The
599        // serialiser converts `</` to `<\/` so the JSON parser
600        // accepts it but no DOM parser sees a closing tag.
601        let entry = CaseCapture {
602            label: "label".into(),
603            method: "GET".into(),
604            url: "/x".into(),
605            request_headers: BTreeMap::new(),
606            request_body: None,
607            request_body_truncated: false,
608            response_status: 200,
609            response_headers: BTreeMap::new(),
610            response_body: Some("<script>alert(1)</script>".into()),
611            response_body_truncated: false,
612            error: None,
613            response_schema_error: None,
614            expected_status_range: "2xx-3xx".to_string(),
615            path_template: String::new(),
616            spec_label: None,
617            mockforge_version: String::new(),
618            client_sent_at: String::new(),
619        };
620        let html = render_capture_html(&[entry]);
621        assert!(
622            !html.contains("</script>alert(1)</script>"),
623            "raw `</script>` snuck into the embedded data"
624        );
625        assert!(html.contains("<\\/script>"), "missing escaped form");
626    }
627}