1use super::self_test::CaseCapture;
19
20#[allow(dead_code)] const PAGE_SIZE: usize = 50;
29
30pub 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 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
74fn 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 _ => (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
107fn push_data_script(out: &mut String, entries: &[CaseCapture]) {
115 out.push_str("<script id=\"captureData\" type=\"application/json\">\n");
116 let json = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string());
120 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, '&')
211 .replace(/</g, '<')
212 .replace(/>/g, '>')
213 .replace(/"/g, '"')
214 .replace(/'/g, ''');
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 += ' · ';
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 iteration: 1,
420 },
421 CaseCapture {
422 label: "owasp:sqli".to_string(),
423 method: "GET".to_string(),
424 url: "http://target/users?id=' OR 1=1".to_string(),
425 request_headers: BTreeMap::new(),
426 request_body: None,
427 request_body_truncated: false,
428 response_status: 500,
429 response_headers: resp_h,
430 response_body: Some("[]".to_string()),
431 response_body_truncated: false,
432 error: None,
433 response_schema_error: None,
434 expected_status_range: "2xx-3xx".to_string(),
435 path_template: String::new(),
436 spec_label: None,
437 mockforge_version: String::new(),
438 client_sent_at: String::new(),
439 iteration: 1,
440 },
441 ]
442 }
443
444 #[test]
445 fn embeds_all_probes_as_json() {
446 let html = render_capture_html(&sample());
447 assert!(html.contains("id=\"captureData\""));
449 assert!(html.contains("\"positive\""));
451 assert!(html.contains("\"owasp:sqli\""));
452 }
453
454 #[test]
459 fn no_silent_cap_past_one_thousand() {
460 let mut entries = Vec::with_capacity(1500);
461 for i in 0..1500 {
462 entries.push(CaseCapture {
463 label: format!("probe-{i}"),
464 method: "GET".into(),
465 url: format!("/path/{i}"),
466 request_headers: BTreeMap::new(),
467 request_body: None,
468 request_body_truncated: false,
469 response_status: if i % 4 == 0 { 500 } else { 200 },
470 response_headers: BTreeMap::new(),
471 response_body: None,
472 response_body_truncated: false,
473 error: None,
474 response_schema_error: None,
475 expected_status_range: "2xx-3xx".to_string(),
476 path_template: String::new(),
477 spec_label: None,
478 mockforge_version: String::new(),
479 client_sent_at: String::new(),
480 iteration: 1,
481 });
482 }
483 let html = render_capture_html(&entries);
484 assert!(html.contains("\"probe-1234\""));
486 assert!(html.contains("\"probe-1499\""));
487 assert!(!html.contains("Showing first 1000 of"));
489 }
490
491 #[test]
492 fn pagination_controls_present() {
493 let html = render_capture_html(&sample());
494 for id in [
495 "firstPage",
496 "prevPage",
497 "nextPage",
498 "lastPage",
499 "jumpPage",
500 "pageNum",
501 ] {
502 assert!(html.contains(id), "missing pagination control: {id}");
503 }
504 assert!(html.contains("PAGE_SIZE = 50"));
505 }
506
507 #[test]
508 fn empty_capture_still_produces_valid_html() {
509 let html = render_capture_html(&[]);
510 assert!(html.starts_with("<!doctype html>"));
511 assert!(html.contains("0 probe(s)"));
512 assert!(html.contains("</html>"));
513 }
514
515 fn cap_with_range(status: u16, expected_range: &str) -> CaseCapture {
516 CaseCapture {
517 label: "x".to_string(),
518 method: "POST".to_string(),
519 url: "http://t/x".to_string(),
520 request_headers: BTreeMap::new(),
521 request_body: None,
522 request_body_truncated: false,
523 response_status: status,
524 response_headers: BTreeMap::new(),
525 response_body: None,
526 response_body_truncated: false,
527 error: None,
528 response_schema_error: None,
529 expected_status_range: expected_range.to_string(),
530 path_template: String::new(),
531 spec_label: None,
532 mockforge_version: String::new(),
533 client_sent_at: String::new(),
534 iteration: 1,
535 }
536 }
537
538 #[test]
543 fn probe_passed_honours_expected_status_range() {
544 assert!(
546 probe_passed(&cap_with_range(400, "2xx-4xx")),
547 "Srikanth's local-accounts 400 case: variant-b probe expecting 2xx-4xx must pass on 400"
548 );
549 assert!(probe_passed(&cap_with_range(204, "2xx-4xx")));
550 assert!(probe_passed(&cap_with_range(415, "2xx-4xx")));
551 assert!(
552 !probe_passed(&cap_with_range(500, "2xx-4xx")),
553 "5xx is the only failure mode for variant-b"
554 );
555
556 assert!(probe_passed(&cap_with_range(404, "4xx")));
558 assert!(probe_passed(&cap_with_range(422, "4xx")));
559 assert!(!probe_passed(&cap_with_range(200, "4xx")));
560 assert!(!probe_passed(&cap_with_range(500, "4xx")));
561
562 assert!(probe_passed(&cap_with_range(200, "2xx-3xx")));
564 assert!(probe_passed(&cap_with_range(301, "2xx-3xx")));
565 assert!(!probe_passed(&cap_with_range(400, "2xx-3xx")));
566
567 assert!(probe_passed(&cap_with_range(200, "")));
569 assert!(!probe_passed(&cap_with_range(400, "")));
570
571 let mut err = cap_with_range(0, "2xx-4xx");
573 err.error = Some("connection refused".to_string());
574 assert!(!probe_passed(&err));
575 }
576
577 #[test]
582 fn summary_counts_variant_b_400_as_pass() {
583 let entries = vec![
584 cap_with_range(400, "2xx-4xx"),
585 cap_with_range(400, "2xx-4xx"),
586 cap_with_range(500, "2xx-4xx"),
587 cap_with_range(200, "2xx-3xx"),
588 ];
589 let html = render_capture_html(&entries);
590 assert!(
593 html.contains("3 matched the expected range, 1 did not"),
594 "summary line should count the two 400 / 2xx-4xx rows as passes; got:\n{}",
595 html.lines().find(|l| l.contains("matched")).unwrap_or("<no match line>")
596 );
597 }
598
599 #[test]
600 fn embedded_json_breaks_inline_script_terminators() {
601 let entry = CaseCapture {
606 label: "label".into(),
607 method: "GET".into(),
608 url: "/x".into(),
609 request_headers: BTreeMap::new(),
610 request_body: None,
611 request_body_truncated: false,
612 response_status: 200,
613 response_headers: BTreeMap::new(),
614 response_body: Some("<script>alert(1)</script>".into()),
615 response_body_truncated: false,
616 error: None,
617 response_schema_error: None,
618 expected_status_range: "2xx-3xx".to_string(),
619 path_template: String::new(),
620 spec_label: None,
621 mockforge_version: String::new(),
622 client_sent_at: String::new(),
623 iteration: 1,
624 };
625 let html = render_capture_html(&[entry]);
626 assert!(
627 !html.contains("</script>alert(1)</script>"),
628 "raw `</script>` snuck into the embedded data"
629 );
630 assert!(html.contains("<\\/script>"), "missing escaped form");
631 }
632}