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_category_table(&mut html, report, &anchors);
70 push_operations_table(&mut html, report, opts, &anchors);
71 if let Some(a) = audit {
72 push_spec_audit(&mut html, a);
73 }
74 html.push_str(FOOT);
75 html
76}
77
78fn compute_anchor_set(report: &SelfTestReport, opts: &RenderOptions) -> AnchorSet {
85 let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
86 for op in &report.operations {
87 for neg in &op.negatives {
88 if !neg.passed {
89 missed.push((op, neg));
90 }
91 }
92 }
93 let take = opts.missed_cap.unwrap_or(usize::MAX);
94 let mut cats: std::collections::HashSet<String> = std::collections::HashSet::new();
95 let mut ops: std::collections::HashSet<String> = std::collections::HashSet::new();
96 for (op, neg) in missed.iter().take(take) {
97 let cat = neg.label.split(':').next().unwrap_or("other").to_string();
98 cats.insert(cat);
99 ops.insert(op_anchor_slug(&op.method, &op.path));
100 }
101 AnchorSet { cats, ops }
102}
103
104#[derive(Default)]
107struct AnchorSet {
108 cats: std::collections::HashSet<String>,
109 ops: std::collections::HashSet<String>,
110}
111
112const HEAD: &str = r#"<!doctype html>
114<html lang="en">
115<head>
116<meta charset="utf-8">
117<title>MockForge Conformance Report</title>
118<style>
119 body { font-family: -apple-system, system-ui, sans-serif; max-width: 1100px;
120 margin: 2rem auto; padding: 0 1rem; color: #1f2933; line-height: 1.5; }
121 h1 { font-size: 1.8rem; margin: 0 0 0.5rem; }
122 h2 { font-size: 1.3rem; margin: 2rem 0 0.5rem; border-bottom: 1px solid #d1d5db; padding-bottom: 0.3rem; }
123 .meta { color: #6b7280; font-size: 0.9rem; }
124 .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin: 1rem 0; }
125 .card { padding: 0.75rem 1rem; border-radius: 6px; background: #f3f4f6; }
126 .card .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
127 .card .value { font-size: 1.6rem; font-weight: 600; color: #1f2933; }
128 .card.ok { background: #ecfdf5; } .card.ok .value { color: #047857; }
129 .card.warn { background: #fffbeb; } .card.warn .value { color: #b45309; }
130 .card.err { background: #fef2f2; } .card.err .value { color: #b91c1c; }
131 table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1.5rem; font-size: 0.9rem; }
132 th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
133 th { background: #f9fafb; font-weight: 600; color: #374151; }
134 tr:hover { background: #f9fafb; }
135 .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
136 .badge.pass { background: #d1fae5; color: #047857; }
137 .badge.fail { background: #fee2e2; color: #b91c1c; }
138 .badge.info { background: #dbeafe; color: #1d4ed8; }
139 .badge.warn { background: #fef3c7; color: #92400e; }
140 .badge.err { background: #fee2e2; color: #b91c1c; }
141 .small { color: #6b7280; font-size: 0.85rem; }
142 code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
143</style>
144</head>
145<body>
146"#;
147
148const FOOT: &str = "\n</body>\n</html>\n";
149
150fn push_header(out: &mut String, _report: &SelfTestReport) {
151 out.push_str("<h1>MockForge Conformance Report</h1>\n");
152 out.push_str(
158 "<p class=\"meta\">Generated by <code>mockforge bench --conformance-self-test</code>. \
159 Probe-label reference: \
160 <a href=\"https://docs.mockforge.dev/reference/conformance-self-test-probes.html\">\
161 docs.mockforge.dev/reference/conformance-self-test-probes</a>.</p>\n",
162 );
163}
164
165fn push_summary_cards(out: &mut String, report: &SelfTestReport) {
166 let positives = report.positive_pass + report.positive_fail;
167 let neg_caught: usize = report.negative_caught.values().sum();
168 let neg_missed: usize = report.negative_missed.values().sum();
169 let pos_class = if report.positive_fail == 0 {
170 "ok"
171 } else {
172 "err"
173 };
174 let miss_class = if neg_missed == 0 { "ok" } else { "warn" };
175 out.push_str("<div class=\"cards\">\n");
176 push_card(out, "Positive cases", positives, pos_class);
177 push_card(out, "Positive failures", report.positive_fail, pos_class);
178 push_card(out, "Negatives matched (4xx)", neg_caught, "ok");
179 push_card(out, "Negatives mismatched (non-4xx)", neg_missed, miss_class);
180 push_card(out, "Operations", report.operations.len(), "");
181 out.push_str("</div>\n");
182}
183
184fn push_card(out: &mut String, label: &str, value: usize, class: &str) {
185 let class_attr = if class.is_empty() {
186 String::new()
187 } else {
188 format!(" {}", class)
189 };
190 out.push_str(&format!(
191 " <div class=\"card{class_attr}\"><div class=\"label\">{}</div><div class=\"value\">{}</div></div>\n",
192 html_escape(label),
193 value
194 ));
195}
196
197fn push_category_table(out: &mut String, report: &SelfTestReport, anchors: &AnchorSet) {
198 out.push_str("<h2>Negatives by category</h2>\n");
199 let mut keys: Vec<&String> =
200 report.negative_caught.keys().chain(report.negative_missed.keys()).collect();
201 keys.sort();
202 keys.dedup();
203 if keys.is_empty() {
204 out.push_str("<p class=\"small\">No negative probes ran — typically means no operations had any injectable surface.</p>\n");
205 return;
206 }
207 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");
208 for cat in keys {
209 let caught = report.negative_caught.get(cat).copied().unwrap_or(0);
210 let missed = report.negative_missed.get(cat).copied().unwrap_or(0);
211 let (badge_class, badge_text) = if missed == 0 {
218 ("pass", "PASS")
219 } else {
220 ("fail", "FAIL")
221 };
222 let missed_cell = if missed > 0 && anchors.cats.contains(cat) {
229 format!("<a href=\"#miss-cat-{}\">{}</a>", html_escape(cat), missed)
230 } else {
231 missed.to_string()
232 };
233 out.push_str(&format!(
234 "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td></tr>\n",
235 html_escape(cat),
236 caught,
237 missed_cell,
238 badge_class,
239 badge_text
240 ));
241 }
242 out.push_str("</tbody></table>\n");
243}
244
245fn push_operations_table(
246 out: &mut String,
247 report: &SelfTestReport,
248 opts: &RenderOptions,
249 anchors: &AnchorSet,
250) {
251 out.push_str("<h2>Per-operation results</h2>\n");
252 if report.operations.is_empty() {
253 out.push_str("<p class=\"small\">No operations.</p>\n");
254 return;
255 }
256 out.push_str("<table>\n<thead><tr><th>Method</th><th>Path</th><th>Positive</th><th>Matched / Mismatched</th></tr></thead>\n<tbody>\n");
257 for op in &report.operations {
258 let pos_badge = match &op.positive {
259 Some(p) if p.passed => "<span class=\"badge pass\">2xx ✓</span>".to_string(),
260 Some(p) => format!("<span class=\"badge fail\">{} ✗</span>", p.actual_status),
261 None => "<span class=\"badge info\">none</span>".into(),
262 };
263 let (caught, missed) = op.negatives.iter().partition::<Vec<&CaseOutcome>, _>(|n| n.passed);
264 let op_slug = op_anchor_slug(&op.method, &op.path);
270 let missed_cell = if missed.is_empty() {
271 "0".to_string()
272 } else if anchors.ops.contains(&op_slug) {
273 format!("<a href=\"#miss-op-{}\">{}</a>", op_slug, missed.len())
274 } else {
275 missed.len().to_string()
276 };
277 out.push_str(&format!(
278 "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td><td>{} / {}</td></tr>\n",
279 html_escape(&op.method),
280 html_escape(&op.path),
281 pos_badge,
282 caught.len(),
283 missed_cell
284 ));
285 }
286 out.push_str("</tbody></table>\n");
287 push_missed_detail(out, report, opts);
288}
289
290fn op_anchor_slug(method: &str, path: &str) -> String {
297 let mut s = format!("{method}_{path}");
298 s = s.to_ascii_lowercase();
299 s = s.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
300 s
301}
302
303fn expected_status_label(case: &CaseOutcome) -> &'static str {
307 if case.expected_4xx {
308 "4xx (reject)"
309 } else {
310 "2xx-3xx (accept)"
311 }
312}
313
314fn push_missed_detail(out: &mut String, report: &SelfTestReport, opts: &RenderOptions) {
315 let mut missed: Vec<(&OperationResult, &CaseOutcome)> = Vec::new();
320 for op in &report.operations {
321 for neg in &op.negatives {
322 if !neg.passed {
323 missed.push((op, neg));
324 }
325 }
326 }
327 if missed.is_empty() {
328 return;
329 }
330 out.push_str(
331 "<h2>Mismatched negatives (server returned non-4xx to a probe expecting 4xx)</h2>\n",
332 );
333 let total = missed.len();
336 let cap_msg = match opts.missed_cap {
337 Some(cap) if total > cap => format!(
338 "{} 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>.",
339 total, cap
340 ),
341 Some(_) => format!("{} mismatched negative(s). All shown.", total),
342 None => format!("{} mismatched negative(s). All shown (no cap).", total),
343 };
344 out.push_str(&format!("<p class=\"small\">{cap_msg}</p>\n"));
345 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");
346 let take = opts.missed_cap.unwrap_or(usize::MAX);
347 let mut seen_cat: std::collections::HashSet<String> = std::collections::HashSet::new();
356 let mut seen_op: std::collections::HashSet<String> = std::collections::HashSet::new();
357 for (op, neg) in missed.iter().take(take) {
358 let cat = neg.label.split(':').next().unwrap_or("other").to_string();
359 let op_slug = op_anchor_slug(&op.method, &op.path);
360 let tr_id = if seen_cat.insert(cat.clone()) {
361 format!(" id=\"miss-cat-{}\"", html_escape(&cat))
362 } else {
363 String::new()
364 };
365 let op_anchor = if seen_op.insert(op_slug.clone()) {
366 format!("<span id=\"miss-op-{op_slug}\"></span>")
367 } else {
368 String::new()
369 };
370 out.push_str(&format!(
371 "<tr{}><td>{}<code>{}</code></td><td><code>{}</code></td><td><code>{}</code></td><td><span class=\"badge info\">{}</span></td><td>{}</td></tr>\n",
372 tr_id,
373 op_anchor,
374 html_escape(&op.method),
375 html_escape(&op.path),
376 html_escape(&neg.label),
377 expected_status_label(neg),
378 neg.actual_status
379 ));
380 }
381 out.push_str("</tbody></table>\n");
382}
383
384fn push_spec_audit(out: &mut String, audit: &serde_json::Value) {
385 out.push_str("<h2>Spec audit</h2>\n");
386 let findings = audit.get("findings").and_then(|v| v.as_array());
387 let coverage = audit.get("datatype_coverage").and_then(|v| v.as_object());
388 let ops = audit.get("operations_audited").and_then(|v| v.as_u64()).unwrap_or(0);
389 out.push_str(&format!(
390 "<p class=\"small\">Audited {ops} operation(s). Coverage map: {} datatype kind(s).</p>\n",
391 coverage.map(|c| c.len()).unwrap_or(0)
392 ));
393 if let Some(findings) = findings {
394 if findings.is_empty() {
395 out.push_str("<p class=\"small\">No findings.</p>\n");
396 } else {
397 let mut by_sev: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
399 for f in findings {
400 let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
401 by_sev.entry(sev).or_default().push(f);
402 }
403 out.push_str("<table>\n<thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Message</th></tr></thead>\n<tbody>\n");
404 for (sev, items) in by_sev {
405 let badge_class = match sev.as_str() {
406 "error" => "err",
407 "warning" => "warn",
408 _ => "info",
409 };
410 for item in items {
411 let cat = item.get("category").and_then(|v| v.as_str()).unwrap_or("");
412 let loc = item.get("location").and_then(|v| v.as_str()).unwrap_or("");
413 let msg = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
414 out.push_str(&format!(
415 "<tr><td><span class=\"badge {}\">{}</span></td><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>\n",
416 badge_class,
417 html_escape(&sev),
418 html_escape(cat),
419 html_escape(loc),
420 html_escape(msg)
421 ));
422 }
423 }
424 out.push_str("</tbody></table>\n");
425 }
426 }
427 if let Some(coverage) = coverage {
428 let mut entries: Vec<(&String, u64)> =
429 coverage.iter().filter_map(|(k, v)| v.as_u64().map(|c| (k, c))).collect();
430 entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
431 if !entries.is_empty() {
432 out.push_str("<h2>Datatype coverage</h2>\n");
433 out.push_str("<table>\n<thead><tr><th>Type</th><th>Count</th></tr></thead>\n<tbody>\n");
434 for (kind, count) in entries.iter().take(40) {
435 out.push_str(&format!(
436 "<tr><td><code>{}</code></td><td>{}</td></tr>\n",
437 html_escape(kind),
438 count
439 ));
440 }
441 out.push_str("</tbody></table>\n");
442 }
443 }
444}
445
446fn html_escape(s: &str) -> String {
447 let mut out = String::with_capacity(s.len());
448 for c in s.chars() {
449 match c {
450 '&' => out.push_str("&"),
451 '<' => out.push_str("<"),
452 '>' => out.push_str(">"),
453 '"' => out.push_str("""),
454 '\'' => out.push_str("'"),
455 _ => out.push(c),
456 }
457 }
458 out
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::conformance::self_test::{CaseOutcome, OperationResult, SelfTestReport};
465
466 fn sample_report() -> SelfTestReport {
467 SelfTestReport {
468 positive_pass: 3,
469 positive_fail: 1,
470 negative_caught: BTreeMap::from([("request-body".into(), 4), ("parameters".into(), 2)]),
471 negative_missed: BTreeMap::from([("owasp".into(), 1)]),
472 operations: vec![OperationResult {
473 method: "POST".into(),
474 path: "/users".into(),
475 positive: Some(CaseOutcome {
476 label: "positive".into(),
477 expected_4xx: false,
478 actual_status: 201,
479 passed: true,
480 }),
481 negatives: vec![CaseOutcome {
482 label: "owasp:sqli".into(),
483 expected_4xx: true,
484 actual_status: 200,
485 passed: false,
486 }],
487 }],
488 }
489 }
490
491 #[test]
492 fn html_contains_expected_sections() {
493 let html = render_html(&sample_report(), None);
494 assert!(html.contains("<title>MockForge Conformance Report</title>"));
495 assert!(html.contains("Positive cases"));
496 assert!(html.contains("Negatives by category"));
497 assert!(html.contains("Per-operation results"));
498 assert!(html.contains("Mismatched negatives"));
500 assert!(html.contains("request-body"));
502 assert!(html.contains("owasp:sqli"));
503 assert!(html.contains("/users"));
504 }
505
506 #[test]
507 fn html_renders_audit_section_when_present() {
508 let audit = serde_json::json!({
509 "findings": [
510 {"category": "servers", "severity": "warning",
511 "location": "#/servers", "message": "no servers declared"}
512 ],
513 "datatype_coverage": {"string": 5, "integer": 3},
514 "operations_audited": 7
515 });
516 let html = render_html(&sample_report(), Some(&audit));
517 assert!(html.contains("Spec audit"));
518 assert!(html.contains("no servers declared"));
519 assert!(html.contains("Datatype coverage"));
520 assert!(html.contains("string"));
521 assert!(html.contains("Audited 7 operation"));
522 }
523
524 #[test]
525 fn html_escapes_special_chars_in_labels() {
526 let mut report = sample_report();
527 report.operations[0].path = "/items/<script>".into();
528 report.operations[0].negatives[0].label = "owasp:xss:<>\"&".into();
529 let html = render_html(&report, None);
530 assert!(!html.contains("/items/<script>"));
532 assert!(html.contains("<script>"));
533 assert!(html.contains("""));
534 }
535
536 #[test]
537 fn html_handles_empty_report() {
538 let html = render_html(&SelfTestReport::default(), None);
539 assert!(html.contains("No negative probes ran"));
540 assert!(html.contains("No operations."));
541 }
542
543 #[test]
544 fn html_caps_missed_detail_at_default_200_rows() {
545 let mut report = SelfTestReport::default();
546 for i in 0..250 {
547 report.operations.push(OperationResult {
548 method: "GET".into(),
549 path: format!("/r/{i}"),
550 positive: None,
551 negatives: vec![CaseOutcome {
552 label: "parameters:missing-query".into(),
553 expected_4xx: true,
554 actual_status: 200,
555 passed: false,
556 }],
557 });
558 }
559 report.negative_missed.insert("parameters".into(), 250);
560 let html = render_html(&report, None);
561 assert!(html.contains("250 mismatched negative"));
563 assert!(html.contains("Showing first 200"));
564 assert!(html.contains("--report-missed-cap"));
565 }
566
567 #[test]
571 fn html_no_cap_shows_all_rows() {
572 let mut report = SelfTestReport::default();
573 for i in 0..50 {
574 report.operations.push(OperationResult {
575 method: "GET".into(),
576 path: format!("/r/{i}"),
577 positive: None,
578 negatives: vec![CaseOutcome {
579 label: "parameters:missing-query".into(),
580 expected_4xx: true,
581 actual_status: 200,
582 passed: false,
583 }],
584 });
585 }
586 let opts = RenderOptions { missed_cap: None };
587 let html = render_html_with_options(&report, None, &opts);
588 assert!(html.contains("50 mismatched negative"));
589 assert!(html.contains("All shown (no cap)"));
590 assert!(!html.contains("Showing first"));
591 }
592
593 #[test]
596 fn html_missed_table_has_expected_column() {
597 let mut report = sample_report();
598 report.operations[0].negatives = vec![CaseOutcome {
601 label: "security:bad-bearer".into(),
602 expected_4xx: true,
603 actual_status: 200,
604 passed: false,
605 }];
606 let html = render_html(&report, None);
607 assert!(html.contains("Expected"), "Expected column header missing");
608 assert!(
609 html.contains("4xx (reject)"),
610 "expected-status badge for negative probe missing"
611 );
612 }
613
614 #[test]
620 fn html_count_links_only_emit_for_visible_anchors() {
621 let mut report = SelfTestReport::default();
622 let cats = ["cat-a", "cat-b", "cat-a", "cat-b"];
628 for (i, c) in cats.iter().enumerate() {
629 report.operations.push(OperationResult {
630 method: "GET".into(),
631 path: format!("/r/{i}"),
632 positive: None,
633 negatives: vec![CaseOutcome {
634 label: format!("{c}:fail-{i}"),
635 expected_4xx: true,
636 actual_status: 200,
637 passed: false,
638 }],
639 });
640 *report.negative_missed.entry((*c).to_string()).or_insert(0) += 1;
641 }
642 let opts = RenderOptions {
643 missed_cap: Some(1),
644 };
645 let html = render_html_with_options(&report, None, &opts);
646 assert!(html.contains("id=\"miss-cat-cat-a\""));
649 assert!(!html.contains("id=\"miss-cat-cat-b\""));
650 assert!(html.contains("<a href=\"#miss-cat-cat-a\">"));
653 assert!(!html.contains("<a href=\"#miss-cat-cat-b\">"));
654 assert!(html.contains("<a href=\"#miss-op-get__r_0\">"));
657 assert!(!html.contains("<a href=\"#miss-op-get__r_2\">"));
658 }
659}