1use super::self_test::{CaseOutcome, OperationResult, SelfTestReport};
22use std::collections::BTreeMap;
23
24pub fn render_html(report: &SelfTestReport, audit: Option<&serde_json::Value>) -> String {
31 render_html_with_options(report, audit, &RenderOptions::default())
32}
33
34#[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
52pub 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 let anchors = compute_anchor_set(report, opts);
69 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
83fn 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#[derive(Default)]
112struct AnchorSet {
113 cats: std::collections::HashSet<String>,
114 ops: std::collections::HashSet<String>,
115}
116
117const 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 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
202fn 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 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 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
253fn 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 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 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 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 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
350fn 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
363fn 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 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 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 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 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("&"),
511 '<' => out.push_str("<"),
512 '>' => out.push_str(">"),
513 '"' => out.push_str("""),
514 '\'' => out.push_str("'"),
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 assert!(html.contains("Mismatched negatives"));
560 assert!(html.contains("request-body"));
562 assert!(html.contains("owasp:sqli"));
563 assert!(html.contains("/users"));
564 assert!(!html.contains("Negatives by category family"));
567 assert!(html.contains("<th>Family</th>"));
568 }
569
570 #[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 assert_eq!(html.matches(">Security</td>").count(), 2);
586 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 assert!(!html.contains("/items/<script>"));
616 assert!(html.contains("<script>"));
617 assert!(html.contains("""));
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 assert!(html.contains("250 mismatched negative"));
647 assert!(html.contains("Showing first 200"));
648 assert!(html.contains("--report-missed-cap"));
649 }
650
651 #[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 #[test]
680 fn html_missed_table_has_expected_column() {
681 let mut report = sample_report();
682 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 #[test]
704 fn html_count_links_only_emit_for_visible_anchors() {
705 let mut report = SelfTestReport::default();
706 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 assert!(html.contains("id=\"miss-cat-cat-a\""));
733 assert!(!html.contains("id=\"miss-cat-cat-b\""));
734 assert!(html.contains("<a href=\"#miss-cat-cat-a\">"));
737 assert!(!html.contains("<a href=\"#miss-cat-cat-b\">"));
738 assert!(html.contains("<a href=\"#miss-op-get__r_0\">"));
741 assert!(!html.contains("<a href=\"#miss-op-get__r_2\">"));
742 }
743}