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(line) = finding.line {
240                    html.push_str(&format!(
241                        r#"<span class="meta-tag">📍 Line {}</span>"#,
242                        line
243                    ));
244                }
245                html.push_str("</div>");
246
247                if !finding.description.is_empty() {
248                    html.push_str(&format!(
249                        r#"<div class="finding-desc">{}</div>"#,
250                        escape_html(&finding.description)
251                    ));
252                }
253                if let Some(snippet) = &finding.code_snippet {
254                    html.push_str(&format!(
255                        r#"<pre class="code-snippet"><code>{}</code></pre>"#,
256                        escape_html(snippet)
257                    ));
258                }
259                html.push_str(&format!(
260                    r#"<div class="finding-rec"><strong>💡 Recommendation:</strong> {}</div>"#,
261                    escape_html(&finding.recommendation)
262                ));
263                if let Some(exploit_path) = &finding.exploit_path {
264                    html.push_str(
265                        r#"<div class="finding-exploit"><strong>🔗 Exploit Path:</strong><ol>"#,
266                    );
267                    for step in exploit_path {
268                        html.push_str(&format!("<li>{}</li>", escape_html(step)));
269                    }
270                    html.push_str("</ol></div>");
271                }
272                if !finding.references.is_empty() {
273                    html.push_str(
274                        r#"<div class="finding-refs"><strong>📎 References:</strong><ul>"#,
275                    );
276                    for ref_ in &finding.references {
277                        html.push_str(&format!("<li>{}</li>", escape_html(ref_)));
278                    }
279                    html.push_str("</ul></div>");
280                }
281                html.push_str("</div>\n");
282            }
283            html.push_str("</details>\n");
284        }
285        html.push_str("</div>\n");
286    }
287
288    // ── Footer ──
289    html.push_str(&format!(
290        r#"<footer>
291            <p>Report generated by <a href="https://github.com/codetibo/forge-guard">Forge Guard</a>
292            on {}</p>
293        </footer>"#,
294        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
295    ));
296
297    html.push_str("</div>\n</body>\n</html>\n");
298    Ok(html)
299}
300
301// ── Stylesheet ───────────────────────────────────────────────────
302
303const STYLESHEET: &str = r##"
304*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
305:root {
306    --bg: #0f172a; --surface: #1e293b; --surface2: #334155;
307    --text: #f1f5f9; --text2: #94a3b8; --accent: #38bdf8;
308    --critical: #dc2626; --high: #f97316; --medium: #eab308;
309    --low: #3b82f6; --info: #6b7280;
310    --border: #334155; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.3);
311}
312body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
313       background: var(--bg); color: var(--text); line-height: 1.6; padding: 20px; }
314.container { max-width: 960px; margin: 0 auto; }
315
316header { text-align: center; padding: 32px 0 16px; }
317.logo { font-size: 28px; font-weight: 800; background: linear-gradient(135deg,#38bdf8,#818cf8);
318        -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
319.subtitle { font-size: 14px; color: var(--text2); margin-top: 4px; letter-spacing: 1px;
320            text-transform: uppercase; }
321
322.meta-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
323            background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
324            padding: 16px 20px; margin: 16px 0; }
325.meta-item { display: flex; flex-direction: column; align-items: center; min-width: 90px; }
326.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
327.meta-value { font-size: 15px; font-weight: 600; margin-top: 2px; }
328
329.verdict { text-align: center; padding: 20px; border-radius: var(--radius);
330           margin: 16px 0; font-size: 18px; font-weight: 700; }
331.verdict-pass { background: linear-gradient(135deg,#064e3b,#065f46);
332                border: 1px solid #22c55e; color: #bbf7d0; }
333.verdict-fail { background: linear-gradient(135deg,#450a0a,#7f1d1d);
334                border: 1px solid #ef4444; color: #fecaca; }
335
336.section { background: var(--surface); border: 1px solid var(--border);
337            border-radius: var(--radius); padding: 24px; margin: 16px 0; }
338.section h2 { font-size: 18px; font-weight: 700; margin-bottom: 16px;
339              display: flex; align-items: center; gap: 8px; }
340
341.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
342              gap: 12px; }
343.score-card { background: var(--surface2); border-radius: 6px; padding: 16px; }
344.score-label { font-size: 12px; color: var(--text2); text-transform: uppercase;
345               letter-spacing: 0.5px; }
346.score-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
347
348.finding-bar { display: flex; height: 24px; border-radius: 12px; overflow: hidden;
349               background: var(--surface2); }
350.bar-segment { transition: width 0.3s; }
351.bar-critical { background: var(--critical); }
352.bar-high { background: var(--high); }
353.bar-medium { background: var(--medium); }
354.bar-low { background: var(--low); }
355.bar-info { background: var(--info); }
356.finding-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px;
357                  justify-content: center; }
358.legend-item { display: flex; align-items: center; gap: 4px; font-size: 13px; }
359.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
360
361.scores-table { display: flex; flex-direction: column; gap: 8px; }
362.score-row { display: flex; align-items: center; gap: 12px; }
363.score-row-label { width: 160px; font-size: 13px; flex-shrink: 0; }
364.score-row-bar { flex: 1; height: 20px; background: var(--surface2); border-radius: 10px;
365                 overflow: hidden; }
366.score-row-fill { height: 100%; border-radius: 10px; transition: width 0.3s; }
367.score-row-value { width: 60px; text-align: right; font-size: 13px; font-weight: 700;
368                   flex-shrink: 0; }
369
370.badge { background: var(--accent); color: #0f172a; font-size: 13px; font-weight: 700;
371         padding: 2px 10px; border-radius: 12px; }
372
373details { margin-bottom: 12px; }
374summary { cursor: pointer; padding: 12px 16px; border-radius: 6px; font-weight: 700;
375          font-size: 15px; }
376summary:hover { filter: brightness(1.1); }
377.finding-group-header.critical { background: #450a0a; color: #fca5a5; }
378.finding-group-header.high { background: #431407; color: #fdba74; }
379.finding-group-header.medium { background: #422006; color: #fde68a; }
380.finding-group-header.low { background: #172554; color: #93c5fd; }
381.finding-group-header.info { background: #1f2937; color: #d1d5db; }
382
383.finding-card { background: var(--surface2); border-radius: 6px; padding: 16px;
384                margin: 8px 0; }
385.finding-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
386.finding-id { color: var(--accent); font-family: monospace; font-size: 13px; }
387.finding-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
388.meta-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px;
389            background: var(--surface); border: 1px solid var(--border); }
390.severity-critical { border-color: var(--critical); color: #fca5a5; }
391.severity-high { border-color: var(--high); color: #fdba74; }
392.severity-medium { border-color: var(--medium); color: #fde68a; }
393.severity-low { border-color: var(--low); color: #93c5fd; }
394.finding-desc { font-size: 14px; color: var(--text2); margin-bottom: 8px; }
395.code-snippet { background: #0f172a; border: 1px solid var(--border); border-radius: 4px;
396                padding: 12px; font-size: 13px; overflow-x: auto; margin-bottom: 8px; }
397.code-snippet code { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
398.finding-rec { font-size: 14px; padding: 8px 12px; background: #064e3b;
399               border-radius: 4px; margin-bottom: 8px; }
400.finding-exploit { font-size: 14px; padding: 8px 12px; background: #1c1917;
401                   border-radius: 4px; margin-bottom: 8px; }
402.finding-exploit ol { padding-left: 20px; margin-top: 4px; }
403.finding-refs { font-size: 14px; padding: 8px 12px; background: #172554;
404                border-radius: 4px; }
405.finding-refs ul { padding-left: 20px; margin-top: 4px; }
406
407footer { text-align: center; padding: 24px; color: var(--text2); font-size: 13px; }
408footer a { color: var(--accent); text-decoration: none; }
409footer a:hover { text-decoration: underline; }
410
411@media (max-width: 640px) {
412    body { padding: 12px; }
413    .score-grid { grid-template-columns: 1fr; }
414    .score-row { flex-wrap: wrap; }
415    .score-row-label { width: 100%; }
416    .score-row-value { width: auto; }
417    .meta-bar { flex-direction: column; align-items: stretch; }
418    .meta-item { flex-direction: row; justify-content: space-between; }
419}
420"##;
421
422// ── Helpers ──────────────────────────────────────────────────────
423
424fn escape_html(s: &str) -> String {
425    s.replace('&', "&amp;")
426        .replace('<', "&lt;")
427        .replace('>', "&gt;")
428        .replace('"', "&quot;")
429        .replace('\'', "&#39;")
430}
431
432fn score_color(score: u8) -> &'static str {
433    if score >= 85 {
434        "#22c55e"
435    } else if score >= 70 {
436        "#eab308"
437    } else if score >= 50 {
438        "#f97316"
439    } else {
440        "#ef4444"
441    }
442}
443
444fn risk_color(risk: RiskLevel) -> &'static str {
445    match risk {
446        RiskLevel::Critical => "#ef4444",
447        RiskLevel::High => "#f97316",
448        RiskLevel::Medium => "#eab308",
449        RiskLevel::Low => "#22c55e",
450        RiskLevel::Minimal => "#22c55e",
451    }
452}
453
454/// Write audit result as HTML to a file.
455pub fn write_report(result: &AuditResult, path: &std::path::Path) -> Result<(), ForgeGuardError> {
456    let html = generate_report(result)?;
457    std::fs::write(path, html)?;
458    Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::core::*;
465
466    fn sample_result() -> AuditResult {
467        AuditResult {
468            project_name: "test-project".into(),
469            chain: "ethereum".into(),
470            timestamp: "2026-07-28T12:00:00Z".into(),
471            duration_seconds: 2.5,
472            findings: vec![
473                Finding::builder()
474                    .id("FA-H-001")
475                    .title("Reentrancy Vulnerability")
476                    .description("CEI violation in withdraw function")
477                    .severity(Severity::High)
478                    .file("Vault.sol")
479                    .location(42, 8)
480                    .code("msg.sender.call{value: amount}(\"\");\nbalances[msg.sender] -= amount;")
481                    .recommendation(
482                        "Use ReentrancyGuard or apply checks-effects-interactions pattern",
483                    )
484                    .category("Logic")
485                    .blocks_deployment(true)
486                    .build(),
487                Finding::builder()
488                    .id("FA-M-002")
489                    .title("Unbounded Loop")
490                    .description("Loop over dynamic array may cause out-of-gas")
491                    .severity(Severity::Medium)
492                    .file("Vault.sol")
493                    .location(88, 12)
494                    .code("for (uint i; i < holders.length; i++) { ... }")
495                    .recommendation("Track array length or use pagination")
496                    .category("Gas")
497                    .blocks_deployment(false)
498                    .reference("SWC-128")
499                    .build(),
500                Finding::builder()
501                    .id("FA-L-003")
502                    .title("Unused Variable")
503                    .description("Variable 'oldBalance' is declared but never used")
504                    .severity(Severity::Low)
505                    .file("Vault.sol")
506                    .location(15, 20)
507                    .code("uint256 oldBalance = balances[msg.sender];")
508                    .recommendation("Remove unused variable")
509                    .category("Best Practices")
510                    .blocks_deployment(false)
511                    .build(),
512            ],
513            scores: SecurityScores {
514                access_control: 100,
515                security: 70,
516                fuzzing: 100,
517                gas: 85,
518                architecture: 65,
519                upgradeability: 100,
520                dependencies: 100,
521                deployment: 100,
522                proxy_safety: 100,
523                chain_compatibility: 100,
524                production_readiness: 55,
525                exploit_resistance: 100,
526            },
527            overall_score: 76,
528            risk_level: RiskLevel::High,
529            production_ready: false,
530            deployment_approved: false,
531            summary: AuditSummary {
532                total_findings: 3,
533                critical_count: 0,
534                high_count: 1,
535                medium_count: 1,
536                low_count: 1,
537                info_count: 0,
538                files_analyzed: 5,
539                lines_analyzed: 500,
540                contracts_analyzed: 3,
541            },
542        }
543    }
544
545    #[test]
546    fn test_html_report_contains_basic_elements() {
547        let result = sample_result();
548        let html = generate_report(&result).unwrap();
549        assert!(html.contains("<!DOCTYPE html>"));
550        assert!(html.contains("Forge Guard"));
551        assert!(html.contains("Security Audit Report"));
552        assert!(html.contains("test-project"));
553        assert!(html.contains("ethereum"));
554        assert!(html.contains("FAIL"));
555        assert!(html.contains("76/100"));
556        assert!(html.contains("HIGH"));
557    }
558
559    #[test]
560    fn test_html_report_contains_findings() {
561        let result = sample_result();
562        let html = generate_report(&result).unwrap();
563        assert!(html.contains("Reentrancy Vulnerability"));
564        assert!(html.contains("FA-H-001"));
565        assert!(html.contains("Unbounded Loop"));
566        assert!(html.contains("FA-M-002"));
567        assert!(html.contains("Unused Variable"));
568        assert!(html.contains("FA-L-003"));
569    }
570
571    #[test]
572    fn test_html_report_empty_findings() {
573        let result = AuditResult {
574            project_name: "clean".into(),
575            chain: "base".into(),
576            timestamp: "2026-07-28T00:00:00Z".into(),
577            duration_seconds: 1.0,
578            findings: vec![],
579            scores: SecurityScores::perfect(),
580            overall_score: 100,
581            risk_level: RiskLevel::Minimal,
582            production_ready: true,
583            deployment_approved: true,
584            summary: AuditSummary {
585                total_findings: 0,
586                critical_count: 0,
587                high_count: 0,
588                medium_count: 0,
589                low_count: 0,
590                info_count: 0,
591                files_analyzed: 3,
592                lines_analyzed: 200,
593                contracts_analyzed: 2,
594            },
595        };
596        let html = generate_report(&result).unwrap();
597        assert!(html.contains("PASS"));
598        assert!(html.contains("APPROVED"));
599        assert!(html.contains("100/100"));
600        assert!(html.contains("MINIMAL"));
601    }
602
603    #[test]
604    fn test_html_report_all_severities() {
605        let result = AuditResult {
606            project_name: "all-sev".into(),
607            chain: "polygon".into(),
608            timestamp: "2026-01-01T00:00:00Z".into(),
609            duration_seconds: 0.5,
610            findings: vec![
611                Finding::builder()
612                    .id("C1")
613                    .title("Critical Bug")
614                    .description("")
615                    .severity(Severity::Critical)
616                    .file("c.sol")
617                    .code("x")
618                    .recommendation("Fix")
619                    .category("Security")
620                    .build(),
621                Finding::builder()
622                    .id("H1")
623                    .title("High Bug")
624                    .description("")
625                    .severity(Severity::High)
626                    .file("h.sol")
627                    .code("x")
628                    .recommendation("Fix")
629                    .category("Security")
630                    .build(),
631                Finding::builder()
632                    .id("M1")
633                    .title("Medium Bug")
634                    .description("")
635                    .severity(Severity::Medium)
636                    .file("m.sol")
637                    .code("x")
638                    .recommendation("Fix")
639                    .category("Gas")
640                    .build(),
641                Finding::builder()
642                    .id("L1")
643                    .title("Low Bug")
644                    .description("")
645                    .severity(Severity::Low)
646                    .file("l.sol")
647                    .code("x")
648                    .recommendation("Fix")
649                    .category("Style")
650                    .build(),
651                Finding::builder()
652                    .id("I1")
653                    .title("Info Note")
654                    .description("")
655                    .severity(Severity::Informational)
656                    .file("i.sol")
657                    .code("x")
658                    .recommendation("Note")
659                    .category("Style")
660                    .build(),
661            ],
662            scores: SecurityScores::perfect(),
663            overall_score: 50,
664            risk_level: RiskLevel::High,
665            production_ready: false,
666            deployment_approved: false,
667            summary: AuditSummary {
668                total_findings: 5,
669                critical_count: 1,
670                high_count: 1,
671                medium_count: 1,
672                low_count: 1,
673                info_count: 1,
674                files_analyzed: 1,
675                lines_analyzed: 50,
676                contracts_analyzed: 1,
677            },
678        };
679        let html = generate_report(&result).unwrap();
680        assert!(html.contains("CRITICAL"));
681        assert!(html.contains("HIGH"));
682        assert!(html.contains("MEDIUM"));
683        assert!(html.contains("LOW"));
684        assert!(html.contains("INFO"));
685        // All five finding titles
686        assert!(html.contains("Critical Bug"));
687        assert!(html.contains("High Bug"));
688        assert!(html.contains("Medium Bug"));
689        assert!(html.contains("Low Bug"));
690        assert!(html.contains("Info Note"));
691    }
692
693    #[test]
694    fn test_html_report_contains_exploit_path() {
695        let mut result = sample_result();
696        result.findings[0].exploit_path = Some(vec![
697            "Attacker calls withdraw()".into(),
698            "Fallback receives ETH before balance update".into(),
699            "Re-enters withdraw() recursively".into(),
700        ]);
701        let html = generate_report(&result).unwrap();
702        assert!(html.contains("Exploit Path"));
703        assert!(html.contains("Attacker calls withdraw()"));
704    }
705
706    #[test]
707    fn test_html_report_contains_references() {
708        let html = generate_report(&sample_result()).unwrap();
709        assert!(html.contains("SWC-128"));
710    }
711
712    #[test]
713    fn test_html_escape_prevents_injection() {
714        let result = AuditResult {
715            project_name: "<script>alert('xss')</script>".into(),
716            chain: "test".into(),
717            timestamp: "2026-01-01T00:00:00Z".into(),
718            duration_seconds: 0.1,
719            findings: vec![Finding::builder()
720                .id("X1")
721                .title("<img src=x onerror=alert(1)>")
722                .description("<b>bold</b>")
723                .severity(Severity::Low)
724                .file("<script>evil</script>")
725                .code("<script>alert(1)</script>")
726                .recommendation("Sanitize")
727                .category("Security")
728                .build()],
729            scores: SecurityScores::perfect(),
730            overall_score: 100,
731            risk_level: RiskLevel::Minimal,
732            production_ready: true,
733            deployment_approved: true,
734            summary: AuditSummary {
735                total_findings: 1,
736                critical_count: 0,
737                high_count: 0,
738                medium_count: 0,
739                low_count: 1,
740                info_count: 0,
741                files_analyzed: 1,
742                lines_analyzed: 10,
743                contracts_analyzed: 1,
744            },
745        };
746        let html = generate_report(&result).unwrap();
747        // HTML tags should be escaped, not rendered
748        assert!(html.contains("&lt;script&gt;"));
749        assert!(html.contains("&lt;img"));
750        assert!(html.contains("&lt;b&gt;"));
751        assert!(!html.contains("<script>alert"));
752        assert!(!html.contains("<img src=x"));
753    }
754
755    #[test]
756    fn test_html_report_score_colors() {
757        assert_eq!(score_color(100), "#22c55e");
758        assert_eq!(score_color(85), "#22c55e");
759        assert_eq!(score_color(75), "#eab308");
760        assert_eq!(score_color(70), "#eab308");
761        assert_eq!(score_color(60), "#f97316");
762        assert_eq!(score_color(50), "#f97316");
763        assert_eq!(score_color(30), "#ef4444");
764        assert_eq!(score_color(0), "#ef4444");
765    }
766
767    #[test]
768    fn test_html_report_risk_colors() {
769        assert_eq!(risk_color(RiskLevel::Critical), "#ef4444");
770        assert_eq!(risk_color(RiskLevel::High), "#f97316");
771        assert_eq!(risk_color(RiskLevel::Medium), "#eab308");
772        assert_eq!(risk_color(RiskLevel::Low), "#22c55e");
773        assert_eq!(risk_color(RiskLevel::Minimal), "#22c55e");
774    }
775
776    #[test]
777    fn test_html_write_report() {
778        let result = sample_result();
779        let dir = tempfile::tempdir().unwrap();
780        let path = dir.path().join("report.html");
781        write_report(&result, &path).unwrap();
782        assert!(path.exists());
783        let content = std::fs::read_to_string(&path).unwrap();
784        assert!(content.contains("Forge Guard"));
785    }
786
787    #[test]
788    fn test_html_report_responsive_viewport() {
789        let html = generate_report(&sample_result()).unwrap();
790        assert!(html.contains("viewport"));
791        assert!(html.contains("width=device-width"));
792    }
793
794    #[test]
795    fn test_html_report_dark_theme_css_vars() {
796        let html = generate_report(&sample_result()).unwrap();
797        assert!(html.contains("--bg: #0f172a"));
798    }
799}