Skip to main content

forge_guard/reports/
html.rs

1//! HTML report generation — self-contained responsive audit reports with embedded CSS.
2//!
3//! Produces a single `.html` file with no external dependencies, suitable for
4//! viewing in any browser, sharing with teams, or attaching to CI artifacts.
5
6use crate::core::{AuditResult, Finding, ForgeGuardError, RiskLevel, Severity};
7
8/// Generate a self-contained HTML audit report.
9pub fn generate_report(result: &AuditResult) -> Result<String, ForgeGuardError> {
10    let mut html = String::new();
11    html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
12    html.push_str("<meta charset=\"UTF-8\">\n");
13    html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
14    html.push_str("<title>Forge Guard — Security Audit Report</title>\n");
15    html.push_str("<style>\n");
16    html.push_str(STYLESHEET);
17    html.push_str("</style>\n</head>\n<body>\n");
18    html.push_str("<div class=\"container\">\n");
19
20    // ── Header ──
21    html.push_str(
22        r#"<header>
23            <div class="logo">🔒 Forge Guard</div>
24            <div class="subtitle">Security Audit Report</div>
25        </header>"#,
26    );
27
28    // ── Metadata bar ──
29    html.push_str(r#"<div class="meta-bar">"#);
30    html.push_str(&format!(
31        r#"<div class="meta-item"><span class="meta-label">Project</span><span class="meta-value">{}</span></div>"#,
32        escape_html(&result.project_name)
33    ));
34    html.push_str(&format!(
35        r#"<div class="meta-item"><span class="meta-label">Chain</span><span class="meta-value">{}</span></div>"#,
36        escape_html(&result.chain)
37    ));
38    html.push_str(&format!(
39        r#"<div class="meta-item"><span class="meta-label">Duration</span><span class="meta-value">{:.2}s</span></div>"#,
40        result.duration_seconds
41    ));
42    html.push_str(&format!(
43        r#"<div class="meta-item"><span class="meta-label">Files</span><span class="meta-value">{}</span></div>"#,
44        result.summary.files_analyzed
45    ));
46    html.push_str(&format!(
47        r#"<div class="meta-item"><span class="meta-label">Date</span><span class="meta-value">{}</span></div>"#,
48        &result.timestamp[..10]
49    ));
50    html.push_str("</div>\n");
51
52    // ── Verdict banner ──
53    let verdict_class = if result.deployment_approved && result.production_ready {
54        "verdict-pass"
55    } else {
56        "verdict-fail"
57    };
58    let verdict_text = if result.deployment_approved && result.production_ready {
59        "✅ PASS — Ready for Deployment"
60    } else if result.production_ready {
61        "⚠️ PASS WITH WARNINGS — Review findings before deploying"
62    } else {
63        "❌ FAIL — Must fix issues before deployment"
64    };
65    html.push_str(&format!(
66        r#"<div class="verdict {}"><div class="verdict-text">{}</div></div>"#,
67        verdict_class, verdict_text
68    ));
69
70    // ── Score overview ──
71    html.push_str(r#"<div class="section"><h2>📊 Score Overview</h2><div class="score-grid">"#);
72    let overall_color = score_color(result.overall_score);
73    html.push_str(&format!(
74        r#"<div class="score-card overall" style="border-left: 4px solid {};">
75            <div class="score-label">Overall Score</div>
76            <div class="score-value" style="color: {};">{}/100</div>
77        </div>"#,
78        overall_color, overall_color, result.overall_score
79    ));
80    let risk_color = risk_color(result.risk_level);
81    html.push_str(&format!(
82        r#"<div class="score-card" style="border-left: 4px solid {};">
83            <div class="score-label">Risk Level</div>
84            <div class="score-value" style="color: {};">{}</div>
85        </div>"#,
86        risk_color, risk_color, result.risk_level
87    ));
88    let dep_icon = if result.deployment_approved {
89        "✅"
90    } else {
91        "❌"
92    };
93    let dep_color = if result.deployment_approved {
94        "#22c55e"
95    } else {
96        "#ef4444"
97    };
98    html.push_str(&format!(
99        r#"<div class="score-card" style="border-left: 4px solid {};">
100            <div class="score-label">Deployment</div>
101            <div class="score-value" style="color: {};">{} {}</div>
102        </div>"#,
103        dep_color,
104        dep_color,
105        dep_icon,
106        if result.deployment_approved {
107            "APPROVED"
108        } else {
109            "BLOCKED"
110        }
111    ));
112    html.push_str("</div></div>\n");
113
114    // ── Finding breakdown ──
115    html.push_str(r#"<div class="section"><h2>🐛 Finding Breakdown</h2><div class="finding-bar">"#);
116    let total = result.summary.total_findings.max(1);
117    let crit_pct = result.summary.critical_count as f64 / total as f64 * 100.0;
118    let high_pct = result.summary.high_count as f64 / total as f64 * 100.0;
119    let med_pct = result.summary.medium_count as f64 / total as f64 * 100.0;
120    let low_pct = result.summary.low_count as f64 / total as f64 * 100.0;
121    let info_pct = result.summary.info_count as f64 / total as f64 * 100.0;
122    if crit_pct > 0.0 {
123        html.push_str(&format!(r#"<div class="bar-segment bar-critical" style="width: {:.1}%;" title="Critical: {}"></div>"#, crit_pct, result.summary.critical_count));
124    }
125    if high_pct > 0.0 {
126        html.push_str(&format!(
127            r#"<div class="bar-segment bar-high" style="width: {:.1}%;" title="High: {}"></div>"#,
128            high_pct, result.summary.high_count
129        ));
130    }
131    if med_pct > 0.0 {
132        html.push_str(&format!(r#"<div class="bar-segment bar-medium" style="width: {:.1}%;" title="Medium: {}"></div>"#, med_pct, result.summary.medium_count));
133    }
134    if low_pct > 0.0 {
135        html.push_str(&format!(
136            r#"<div class="bar-segment bar-low" style="width: {:.1}%;" title="Low: {}"></div>"#,
137            low_pct, result.summary.low_count
138        ));
139    }
140    if info_pct > 0.0 {
141        html.push_str(&format!(
142            r#"<div class="bar-segment bar-info" style="width: {:.1}%;" title="Info: {}"></div>"#,
143            info_pct, result.summary.info_count
144        ));
145    }
146    html.push_str("</div><div class=\"finding-legend\">");
147    html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#dc2626;"></span> Critical <strong>{}</strong></span>"#, result.summary.critical_count));
148    html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#f97316;"></span> High <strong>{}</strong></span>"#, result.summary.high_count));
149    html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#eab308;"></span> Medium <strong>{}</strong></span>"#, result.summary.medium_count));
150    html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#3b82f6;"></span> Low <strong>{}</strong></span>"#, result.summary.low_count));
151    html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#6b7280;"></span> Info <strong>{}</strong></span>"#, result.summary.info_count));
152    html.push_str("</div></div>\n");
153
154    // ── Security Scores table ──
155    html.push_str(r#"<div class="section"><h2>📈 Security Scores</h2><div class="scores-table">"#);
156    let score_pairs = [
157        ("🔐 Access Control", result.scores.access_control),
158        ("🛡️ Security", result.scores.security),
159        ("🎯 Fuzzing", result.scores.fuzzing),
160        ("⛽ Gas", result.scores.gas),
161        ("🏗️ Architecture", result.scores.architecture),
162        ("⬆️ Upgradeability", result.scores.upgradeability),
163        ("📦 Dependencies", result.scores.dependencies),
164        ("🚀 Deployment", result.scores.deployment),
165        ("🔗 Proxy Safety", result.scores.proxy_safety),
166        ("⛓️ Chain Compat", result.scores.chain_compatibility),
167        ("✅ Production Ready", result.scores.production_readiness),
168        ("💥 Exploit Resistance", result.scores.exploit_resistance),
169    ];
170    for (label, score) in &score_pairs {
171        let bar_color = score_color(*score);
172        html.push_str(&format!(
173            r#"<div class="score-row">
174                <div class="score-row-label">{}</div>
175                <div class="score-row-bar"><div class="score-row-fill" style="width:{}%;background:{};"></div></div>
176                <div class="score-row-value" style="color:{};">{}/100</div>
177            </div>"#,
178            label, score, bar_color, bar_color, score
179        ));
180    }
181    html.push_str("</div></div>\n");
182
183    // ── Detailed Findings ──
184    if !result.findings.is_empty() {
185        html.push_str(&format!(
186            r#"<div class="section"><h2>📋 Detailed Findings <span class="badge">{}</span></h2>"#,
187            result.findings.len()
188        ));
189
190        let severity_order = [
191            Severity::Critical,
192            Severity::High,
193            Severity::Medium,
194            Severity::Low,
195            Severity::Informational,
196        ];
197        let severity_labels = [
198            ("🛑 CRITICAL", "critical"),
199            ("🔴 HIGH", "high"),
200            ("🟡 MEDIUM", "medium"),
201            ("🔵 LOW", "low"),
202            ("⚪ INFO", "info"),
203        ];
204
205        for (sev_idx, severity) in severity_order.iter().enumerate() {
206            let sev_findings: Vec<&Finding> = result
207                .findings
208                .iter()
209                .filter(|f| f.severity == *severity)
210                .collect();
211            if sev_findings.is_empty() {
212                continue;
213            }
214            let (sev_label, sev_class) = severity_labels[sev_idx];
215            html.push_str(&format!(
216                r#"<details open><summary class="finding-group-header {}">{} ({} found)</summary>"#,
217                sev_class,
218                sev_label,
219                sev_findings.len()
220            ));
221
222            for (i, finding) in sev_findings.iter().enumerate() {
223                html.push_str(&format!(
224                    r#"<div class="finding-card {}">
225                        <div class="finding-title">{}. <span class="finding-id">{}</span> {}</div>
226                        <div class="finding-meta">
227                            <span class="meta-tag severity-{}">{}</span>
228                            <span class="meta-tag">📂 {}</span>
229                            <span class="meta-tag">📁 {}</span>"#,
230                    sev_class,
231                    i + 1,
232                    escape_html(&finding.id),
233                    escape_html(&finding.title),
234                    sev_class,
235                    finding.severity,
236                    escape_html(&finding.category),
237                    finding.file.as_deref().unwrap_or("unknown"),
238                ));
239                if let Some(chain) = &finding.chain {
240                    html.push_str(&format!(
241                        r#"<span class="meta-tag">⛓️ {}</span>"#,
242                        escape_html(chain)
243                    ));
244                }
245                if let Some(line) = finding.line {
246                    html.push_str(&format!(
247                        r#"<span class="meta-tag">📍 Line {}</span>"#,
248                        line
249                    ));
250                }
251                html.push_str("</div>");
252
253                if !finding.description.is_empty() {
254                    html.push_str(&format!(
255                        r#"<div class="finding-desc">{}</div>"#,
256                        escape_html(&finding.description)
257                    ));
258                }
259                if let Some(snippet) = &finding.code_snippet {
260                    html.push_str(&format!(
261                        r#"<pre class="code-snippet"><code>{}</code></pre>"#,
262                        escape_html(snippet)
263                    ));
264                }
265                html.push_str(&format!(
266                    r#"<div class="finding-rec"><strong>💡 Recommendation:</strong> {}</div>"#,
267                    escape_html(&finding.recommendation)
268                ));
269                if let Some(exploit_path) = &finding.exploit_path {
270                    html.push_str(
271                        r#"<div class="finding-exploit"><strong>🔗 Exploit Path:</strong><ol>"#,
272                    );
273                    for step in exploit_path {
274                        html.push_str(&format!("<li>{}</li>", escape_html(step)));
275                    }
276                    html.push_str("</ol></div>");
277                }
278                if !finding.references.is_empty() {
279                    html.push_str(
280                        r#"<div class="finding-refs"><strong>📎 References:</strong><ul>"#,
281                    );
282                    for ref_ in &finding.references {
283                        html.push_str(&format!("<li>{}</li>", escape_html(ref_)));
284                    }
285                    html.push_str("</ul></div>");
286                }
287                html.push_str("</div>\n");
288            }
289            html.push_str("</details>\n");
290        }
291        html.push_str("</div>\n");
292    }
293
294    // ── Footer ──
295    html.push_str(&format!(
296        r#"<footer>
297            <p>Report generated by <a href="https://github.com/codetibo/forge-guard">Forge Guard</a>
298            on {}</p>
299        </footer>"#,
300        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
301    ));
302
303    html.push_str("</div>\n</body>\n</html>\n");
304    Ok(html)
305}
306
307// ── Stylesheet ───────────────────────────────────────────────────
308
309const STYLESHEET: &str = r##"
310*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
311:root {
312    --bg: #0f172a; --surface: #1e293b; --surface2: #334155;
313    --text: #f1f5f9; --text2: #94a3b8; --accent: #38bdf8;
314    --critical: #dc2626; --high: #f97316; --medium: #eab308;
315    --low: #3b82f6; --info: #6b7280;
316    --border: #334155; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.3);
317}
318body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
319       background: var(--bg); color: var(--text); line-height: 1.6; padding: 20px; }
320.container { max-width: 960px; margin: 0 auto; }
321
322header { text-align: center; padding: 32px 0 16px; }
323.logo { font-size: 28px; font-weight: 800; background: linear-gradient(135deg,#38bdf8,#818cf8);
324        -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
325.subtitle { font-size: 14px; color: var(--text2); margin-top: 4px; letter-spacing: 1px;
326            text-transform: uppercase; }
327
328.meta-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
329            background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
330            padding: 16px 20px; margin: 16px 0; }
331.meta-item { display: flex; flex-direction: column; align-items: center; min-width: 90px; }
332.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
333.meta-value { font-size: 15px; font-weight: 600; margin-top: 2px; }
334
335.verdict { text-align: center; padding: 20px; border-radius: var(--radius);
336           margin: 16px 0; font-size: 18px; font-weight: 700; }
337.verdict-pass { background: linear-gradient(135deg,#064e3b,#065f46);
338                border: 1px solid #22c55e; color: #bbf7d0; }
339.verdict-fail { background: linear-gradient(135deg,#450a0a,#7f1d1d);
340                border: 1px solid #ef4444; color: #fecaca; }
341
342.section { background: var(--surface); border: 1px solid var(--border);
343            border-radius: var(--radius); padding: 24px; margin: 16px 0; }
344.section h2 { font-size: 18px; font-weight: 700; margin-bottom: 16px;
345              display: flex; align-items: center; gap: 8px; }
346
347.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
348              gap: 12px; }
349.score-card { background: var(--surface2); border-radius: 6px; padding: 16px; }
350.score-label { font-size: 12px; color: var(--text2); text-transform: uppercase;
351               letter-spacing: 0.5px; }
352.score-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
353
354.finding-bar { display: flex; height: 24px; border-radius: 12px; overflow: hidden;
355               background: var(--surface2); }
356.bar-segment { transition: width 0.3s; }
357.bar-critical { background: var(--critical); }
358.bar-high { background: var(--high); }
359.bar-medium { background: var(--medium); }
360.bar-low { background: var(--low); }
361.bar-info { background: var(--info); }
362.finding-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px;
363                  justify-content: center; }
364.legend-item { display: flex; align-items: center; gap: 4px; font-size: 13px; }
365.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
366
367.scores-table { display: flex; flex-direction: column; gap: 8px; }
368.score-row { display: flex; align-items: center; gap: 12px; }
369.score-row-label { width: 160px; font-size: 13px; flex-shrink: 0; }
370.score-row-bar { flex: 1; height: 20px; background: var(--surface2); border-radius: 10px;
371                 overflow: hidden; }
372.score-row-fill { height: 100%; border-radius: 10px; transition: width 0.3s; }
373.score-row-value { width: 60px; text-align: right; font-size: 13px; font-weight: 700;
374                   flex-shrink: 0; }
375
376.badge { background: var(--accent); color: #0f172a; font-size: 13px; font-weight: 700;
377         padding: 2px 10px; border-radius: 12px; }
378
379details { margin-bottom: 12px; }
380summary { cursor: pointer; padding: 12px 16px; border-radius: 6px; font-weight: 700;
381          font-size: 15px; }
382summary:hover { filter: brightness(1.1); }
383.finding-group-header.critical { background: #450a0a; color: #fca5a5; }
384.finding-group-header.high { background: #431407; color: #fdba74; }
385.finding-group-header.medium { background: #422006; color: #fde68a; }
386.finding-group-header.low { background: #172554; color: #93c5fd; }
387.finding-group-header.info { background: #1f2937; color: #d1d5db; }
388
389.finding-card { background: var(--surface2); border-radius: 6px; padding: 16px;
390                margin: 8px 0; }
391.finding-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
392.finding-id { color: var(--accent); font-family: monospace; font-size: 13px; }
393.finding-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
394.meta-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px;
395            background: var(--surface); border: 1px solid var(--border); }
396.severity-critical { border-color: var(--critical); color: #fca5a5; }
397.severity-high { border-color: var(--high); color: #fdba74; }
398.severity-medium { border-color: var(--medium); color: #fde68a; }
399.severity-low { border-color: var(--low); color: #93c5fd; }
400.finding-desc { font-size: 14px; color: var(--text2); margin-bottom: 8px; }
401.code-snippet { background: #0f172a; border: 1px solid var(--border); border-radius: 4px;
402                padding: 12px; font-size: 13px; overflow-x: auto; margin-bottom: 8px; }
403.code-snippet code { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
404.finding-rec { font-size: 14px; padding: 8px 12px; background: #064e3b;
405               border-radius: 4px; margin-bottom: 8px; }
406.finding-exploit { font-size: 14px; padding: 8px 12px; background: #1c1917;
407                   border-radius: 4px; margin-bottom: 8px; }
408.finding-exploit ol { padding-left: 20px; margin-top: 4px; }
409.finding-refs { font-size: 14px; padding: 8px 12px; background: #172554;
410                border-radius: 4px; }
411.finding-refs ul { padding-left: 20px; margin-top: 4px; }
412
413footer { text-align: center; padding: 24px; color: var(--text2); font-size: 13px; }
414footer a { color: var(--accent); text-decoration: none; }
415footer a:hover { text-decoration: underline; }
416
417@media (max-width: 640px) {
418    body { padding: 12px; }
419    .score-grid { grid-template-columns: 1fr; }
420    .score-row { flex-wrap: wrap; }
421    .score-row-label { width: 100%; }
422    .score-row-value { width: auto; }
423    .meta-bar { flex-direction: column; align-items: stretch; }
424    .meta-item { flex-direction: row; justify-content: space-between; }
425}
426"##;
427
428// ── Helpers ──────────────────────────────────────────────────────
429
430fn escape_html(s: &str) -> String {
431    s.replace('&', "&amp;")
432        .replace('<', "&lt;")
433        .replace('>', "&gt;")
434        .replace('"', "&quot;")
435        .replace('\'', "&#39;")
436}
437
438fn score_color(score: u8) -> &'static str {
439    if score >= 85 {
440        "#22c55e"
441    } else if score >= 70 {
442        "#eab308"
443    } else if score >= 50 {
444        "#f97316"
445    } else {
446        "#ef4444"
447    }
448}
449
450fn risk_color(risk: RiskLevel) -> &'static str {
451    match risk {
452        RiskLevel::Critical => "#ef4444",
453        RiskLevel::High => "#f97316",
454        RiskLevel::Medium => "#eab308",
455        RiskLevel::Low => "#22c55e",
456        RiskLevel::Minimal => "#22c55e",
457    }
458}
459
460/// Write audit result as HTML to a file.
461pub fn write_report(result: &AuditResult, path: &std::path::Path) -> Result<(), ForgeGuardError> {
462    let html = generate_report(result)?;
463    std::fs::write(path, html)?;
464    Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::core::*;
471
472    fn sample_result() -> AuditResult {
473        AuditResult {
474            project_name: "test-project".into(),
475            chain: "ethereum".into(),
476            chains: vec!["ethereum".into()],
477            timestamp: "2026-07-28T12:00:00Z".into(),
478            duration_seconds: 2.5,
479            findings: vec![
480                Finding::builder()
481                    .id("FA-H-001")
482                    .title("Reentrancy Vulnerability")
483                    .description("CEI violation in withdraw function")
484                    .severity(Severity::High)
485                    .file("Vault.sol")
486                    .location(42, 8)
487                    .code("msg.sender.call{value: amount}(\"\");\nbalances[msg.sender] -= amount;")
488                    .recommendation(
489                        "Use ReentrancyGuard or apply checks-effects-interactions pattern",
490                    )
491                    .category("Logic")
492                    .blocks_deployment(true)
493                    .build(),
494                Finding::builder()
495                    .id("FA-M-002")
496                    .title("Unbounded Loop")
497                    .description("Loop over dynamic array may cause out-of-gas")
498                    .severity(Severity::Medium)
499                    .file("Vault.sol")
500                    .location(88, 12)
501                    .code("for (uint i; i < holders.length; i++) { ... }")
502                    .recommendation("Track array length or use pagination")
503                    .category("Gas")
504                    .blocks_deployment(false)
505                    .reference("SWC-128")
506                    .build(),
507                Finding::builder()
508                    .id("FA-L-003")
509                    .title("Unused Variable")
510                    .description("Variable 'oldBalance' is declared but never used")
511                    .severity(Severity::Low)
512                    .file("Vault.sol")
513                    .location(15, 20)
514                    .code("uint256 oldBalance = balances[msg.sender];")
515                    .recommendation("Remove unused variable")
516                    .category("Best Practices")
517                    .blocks_deployment(false)
518                    .build(),
519            ],
520            scores: SecurityScores {
521                access_control: 100,
522                security: 70,
523                fuzzing: 100,
524                gas: 85,
525                architecture: 65,
526                upgradeability: 100,
527                dependencies: 100,
528                deployment: 100,
529                proxy_safety: 100,
530                chain_compatibility: 100,
531                production_readiness: 55,
532                exploit_resistance: 100,
533            },
534            overall_score: 76,
535            risk_level: RiskLevel::High,
536            production_ready: false,
537            deployment_approved: false,
538            summary: AuditSummary {
539                total_findings: 3,
540                critical_count: 0,
541                high_count: 1,
542                medium_count: 1,
543                low_count: 1,
544                info_count: 0,
545                files_analyzed: 5,
546                lines_analyzed: 500,
547                contracts_analyzed: 3,
548            },
549        }
550    }
551
552    #[test]
553    fn test_html_report_contains_basic_elements() {
554        let result = sample_result();
555        let html = generate_report(&result).unwrap();
556        assert!(html.contains("<!DOCTYPE html>"));
557        assert!(html.contains("Forge Guard"));
558        assert!(html.contains("Security Audit Report"));
559        assert!(html.contains("test-project"));
560        assert!(html.contains("ethereum"));
561        assert!(html.contains("FAIL"));
562        assert!(html.contains("76/100"));
563        assert!(html.contains("HIGH"));
564    }
565
566    #[test]
567    fn test_html_report_contains_findings() {
568        let result = sample_result();
569        let html = generate_report(&result).unwrap();
570        assert!(html.contains("Reentrancy Vulnerability"));
571        assert!(html.contains("FA-H-001"));
572        assert!(html.contains("Unbounded Loop"));
573        assert!(html.contains("FA-M-002"));
574        assert!(html.contains("Unused Variable"));
575        assert!(html.contains("FA-L-003"));
576    }
577
578    #[test]
579    fn test_html_report_empty_findings() {
580        let result = AuditResult {
581            project_name: "clean".into(),
582            chain: "base".into(),
583            chains: vec!["base".into()],
584            timestamp: "2026-07-28T00:00:00Z".into(),
585            duration_seconds: 1.0,
586            findings: vec![],
587            scores: SecurityScores::perfect(),
588            overall_score: 100,
589            risk_level: RiskLevel::Minimal,
590            production_ready: true,
591            deployment_approved: true,
592            summary: AuditSummary {
593                total_findings: 0,
594                critical_count: 0,
595                high_count: 0,
596                medium_count: 0,
597                low_count: 0,
598                info_count: 0,
599                files_analyzed: 3,
600                lines_analyzed: 200,
601                contracts_analyzed: 2,
602            },
603        };
604        let html = generate_report(&result).unwrap();
605        assert!(html.contains("PASS"));
606        assert!(html.contains("APPROVED"));
607        assert!(html.contains("100/100"));
608        assert!(html.contains("MINIMAL"));
609    }
610
611    #[test]
612    fn test_html_report_all_severities() {
613        let result = AuditResult {
614            project_name: "all-sev".into(),
615            chain: "polygon".into(),
616            chains: vec!["polygon".into()],
617            timestamp: "2026-01-01T00:00:00Z".into(),
618            duration_seconds: 0.5,
619            findings: vec![
620                Finding::builder()
621                    .id("C1")
622                    .title("Critical Bug")
623                    .description("")
624                    .severity(Severity::Critical)
625                    .file("c.sol")
626                    .code("x")
627                    .recommendation("Fix")
628                    .category("Security")
629                    .build(),
630                Finding::builder()
631                    .id("H1")
632                    .title("High Bug")
633                    .description("")
634                    .severity(Severity::High)
635                    .file("h.sol")
636                    .code("x")
637                    .recommendation("Fix")
638                    .category("Security")
639                    .build(),
640                Finding::builder()
641                    .id("M1")
642                    .title("Medium Bug")
643                    .description("")
644                    .severity(Severity::Medium)
645                    .file("m.sol")
646                    .code("x")
647                    .recommendation("Fix")
648                    .category("Gas")
649                    .build(),
650                Finding::builder()
651                    .id("L1")
652                    .title("Low Bug")
653                    .description("")
654                    .severity(Severity::Low)
655                    .file("l.sol")
656                    .code("x")
657                    .recommendation("Fix")
658                    .category("Style")
659                    .build(),
660                Finding::builder()
661                    .id("I1")
662                    .title("Info Note")
663                    .description("")
664                    .severity(Severity::Informational)
665                    .file("i.sol")
666                    .code("x")
667                    .recommendation("Note")
668                    .category("Style")
669                    .build(),
670            ],
671            scores: SecurityScores::perfect(),
672            overall_score: 50,
673            risk_level: RiskLevel::High,
674            production_ready: false,
675            deployment_approved: false,
676            summary: AuditSummary {
677                total_findings: 5,
678                critical_count: 1,
679                high_count: 1,
680                medium_count: 1,
681                low_count: 1,
682                info_count: 1,
683                files_analyzed: 1,
684                lines_analyzed: 50,
685                contracts_analyzed: 1,
686            },
687        };
688        let html = generate_report(&result).unwrap();
689        assert!(html.contains("CRITICAL"));
690        assert!(html.contains("HIGH"));
691        assert!(html.contains("MEDIUM"));
692        assert!(html.contains("LOW"));
693        assert!(html.contains("INFO"));
694        // All five finding titles
695        assert!(html.contains("Critical Bug"));
696        assert!(html.contains("High Bug"));
697        assert!(html.contains("Medium Bug"));
698        assert!(html.contains("Low Bug"));
699        assert!(html.contains("Info Note"));
700    }
701
702    #[test]
703    fn test_html_report_contains_exploit_path() {
704        let mut result = sample_result();
705        result.findings[0].exploit_path = Some(vec![
706            "Attacker calls withdraw()".into(),
707            "Fallback receives ETH before balance update".into(),
708            "Re-enters withdraw() recursively".into(),
709        ]);
710        let html = generate_report(&result).unwrap();
711        assert!(html.contains("Exploit Path"));
712        assert!(html.contains("Attacker calls withdraw()"));
713    }
714
715    #[test]
716    fn test_html_report_contains_references() {
717        let html = generate_report(&sample_result()).unwrap();
718        assert!(html.contains("SWC-128"));
719    }
720
721    #[test]
722    fn test_html_escape_prevents_injection() {
723        let result = AuditResult {
724            project_name: "<script>alert('xss')</script>".into(),
725            chain: "test".into(),
726            chains: vec!["test".into()],
727            timestamp: "2026-01-01T00:00:00Z".into(),
728            duration_seconds: 0.1,
729            findings: vec![Finding::builder()
730                .id("X1")
731                .title("<img src=x onerror=alert(1)>")
732                .description("<b>bold</b>")
733                .severity(Severity::Low)
734                .file("<script>evil</script>")
735                .code("<script>alert(1)</script>")
736                .recommendation("Sanitize")
737                .category("Security")
738                .build()],
739            scores: SecurityScores::perfect(),
740            overall_score: 100,
741            risk_level: RiskLevel::Minimal,
742            production_ready: true,
743            deployment_approved: true,
744            summary: AuditSummary {
745                total_findings: 1,
746                critical_count: 0,
747                high_count: 0,
748                medium_count: 0,
749                low_count: 1,
750                info_count: 0,
751                files_analyzed: 1,
752                lines_analyzed: 10,
753                contracts_analyzed: 1,
754            },
755        };
756        let html = generate_report(&result).unwrap();
757        // HTML tags should be escaped, not rendered
758        assert!(html.contains("&lt;script&gt;"));
759        assert!(html.contains("&lt;img"));
760        assert!(html.contains("&lt;b&gt;"));
761        assert!(!html.contains("<script>alert"));
762        assert!(!html.contains("<img src=x"));
763    }
764
765    #[test]
766    fn test_html_report_score_colors() {
767        assert_eq!(score_color(100), "#22c55e");
768        assert_eq!(score_color(85), "#22c55e");
769        assert_eq!(score_color(75), "#eab308");
770        assert_eq!(score_color(70), "#eab308");
771        assert_eq!(score_color(60), "#f97316");
772        assert_eq!(score_color(50), "#f97316");
773        assert_eq!(score_color(30), "#ef4444");
774        assert_eq!(score_color(0), "#ef4444");
775    }
776
777    #[test]
778    fn test_html_report_risk_colors() {
779        assert_eq!(risk_color(RiskLevel::Critical), "#ef4444");
780        assert_eq!(risk_color(RiskLevel::High), "#f97316");
781        assert_eq!(risk_color(RiskLevel::Medium), "#eab308");
782        assert_eq!(risk_color(RiskLevel::Low), "#22c55e");
783        assert_eq!(risk_color(RiskLevel::Minimal), "#22c55e");
784    }
785
786    #[test]
787    fn test_html_write_report() {
788        let result = sample_result();
789        let dir = tempfile::tempdir().unwrap();
790        let path = dir.path().join("report.html");
791        write_report(&result, &path).unwrap();
792        assert!(path.exists());
793        let content = std::fs::read_to_string(&path).unwrap();
794        assert!(content.contains("Forge Guard"));
795    }
796
797    #[test]
798    fn test_html_report_responsive_viewport() {
799        let html = generate_report(&sample_result()).unwrap();
800        assert!(html.contains("viewport"));
801        assert!(html.contains("width=device-width"));
802    }
803
804    #[test]
805    fn test_html_report_dark_theme_css_vars() {
806        let html = generate_report(&sample_result()).unwrap();
807        assert!(html.contains("--bg: #0f172a"));
808    }
809}