secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
//! Markdown output format tests.

mod common;

use common::{finding, finding_full};
use secreport::Format;
use secfinding::{Evidence, Finding, Severity};

#[test]
fn markdown_header_contains_tool_and_target() {
    let f = finding("T", Severity::Info);
    let out = secreport::render(&[f], Format::Markdown, "MyTool").unwrap();
    assert!(out.starts_with("# MyTool Security Report"));
    assert!(out.contains("https://example.com"));
}

#[test]
fn markdown_zero_findings_shows_unknown_target() {
    let out = secreport::render(&[] as &[Finding], Format::Markdown, "tool").unwrap();
    assert!(out.starts_with("# "));
    assert!(out.contains("0 findings"));
    assert!(out.contains("unknown"));
}

#[test]
fn markdown_risk_summary_present() {
    let f = finding("T", Severity::High);
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(out.contains("## Risk Summary"));
    assert!(out.contains("| Severity | Count |"));
    assert!(out.contains("| HIGH | 1 |"));
}

#[test]
fn markdown_sections_omitted_when_empty() {
    let f = finding("T", Severity::Info);
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(!out.contains("## Critical Findings"));
    assert!(!out.contains("## High Findings"));
    assert!(!out.contains("## Medium Findings"));
    assert!(!out.contains("## Low Findings"));
    assert!(out.contains("## Informational"));
}

#[test]
fn markdown_all_severity_sections_present() {
    let findings = vec![
        finding("C", Severity::Critical),
        finding("H", Severity::High),
        finding("M", Severity::Medium),
        finding("L", Severity::Low),
        finding("I", Severity::Info),
    ];
    let out = secreport::render(&findings, Format::Markdown, "tool").unwrap();
    assert!(out.contains("## Critical Findings"));
    assert!(out.contains("## High Findings"));
    assert!(out.contains("## Medium Findings"));
    assert!(out.contains("## Low Findings"));
    assert!(out.contains("## Informational"));
}

#[test]
fn markdown_per_finding_fields() {
    let f = finding_full("The Title", Severity::Medium);
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(out.contains("### The Title"));
    assert!(out.contains("**Target:** `https://example.com`"));
    assert!(out.contains(r"**Scanner:** test\-scanner"));
    assert!(out.contains("**Category:** Vulnerability"));
    assert!(out.contains("`web`"));
    assert!(out.contains("`xss`"));
    assert!(out.contains("**CWE:** CWE\\-79"));
    assert!(out.contains("**CVE:** CVE\\-2024\\-12345"));
    assert!(out.contains("Detailed description of the issue."));
    assert!(out.contains("curl -X GET https://example.com/poc"));
}

#[test]
fn markdown_escapes_special_chars() {
    let f = Finding::new("scan*ner", "t", Severity::High, "`code`", "a | b").unwrap();
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(out.contains(r"\`code\`"));
    assert!(out.contains(r"a \| b"));
    assert!(out.contains(r"scan\*ner"));
}

#[test]
fn markdown_escapes_links() {
    let f = Finding::new(
        "s",
        "t",
        Severity::High,
        "[click](javascript:alert(1))",
        "",
    )
    .unwrap();
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(!out.contains("[click](javascript:alert(1))"));
    assert!(out.contains(r"\[click\]"));
}

#[test]
fn markdown_escapes_html_tags() {
    let f = Finding::new("s", "t", Severity::High, "<script>alert(1)</script>", "").unwrap();
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(out.contains(r"\<script\>alert\(1\)\</script\>"));
}

#[test]
fn markdown_tool_name_injection_blocked() {
    let f = finding("T", Severity::Low);
    let out = secreport::render(&[f], Format::Markdown, "evil](/)").unwrap();
    assert!(!out.contains("[evil](/)]("));
    assert!(out.contains("https://github.com/santh-io/"));
}

#[test]
fn markdown_exploit_hint_fence_adaptive() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .exploit_hint("echo ok\n```\nmalicious\n```\nend")
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    // fence must be longer than the longest run of backticks inside hint
    assert!(out.contains("````bash") || out.contains("`````bash"));
    assert!(out.contains("malicious"));
}

#[test]
fn markdown_exploit_hint_six_backticks() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .exploit_hint("``````")
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(out.contains("```````bash") || out.contains("````````bash"));
}

#[test]
fn markdown_footer_link_percent_encoded() {
    let f = finding("T", Severity::Low);
    let out = secreport::render(&[f], Format::Markdown, "tool name").unwrap();
    assert!(out.contains("https://github.com/santh-io/tool%20name"));
}

#[test]
fn markdown_unicode_preserved() {
    let f = Finding::new("s", "t", Severity::Medium, "日本語 🚨", "詳細").unwrap();
    let out = secreport::render(&[f], Format::Markdown, "ツール").unwrap();
    assert!(out.contains("日本語 🚨"));
    assert!(out.contains("詳細"));
    assert!(out.contains("ツール"));
}

#[test]
fn markdown_empty_detail_omitted() {
    let f = Finding::new("s", "t", Severity::High, "T", "").unwrap();
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    // The finding title line should exist but there should be no detail paragraph
    assert!(out.contains("### T"));
}

#[test]
fn markdown_no_exploit_hint_when_missing() {
    let f = finding("T", Severity::High);
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(!out.contains("Exploit / PoC"));
}

#[test]
fn markdown_no_cwe_cve_when_missing() {
    let f = finding("T", Severity::High);
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(!out.contains("**CWE:**"));
    assert!(!out.contains("**CVE:**"));
}

#[test]
fn markdown_no_tags_when_missing() {
    let f = finding("T", Severity::High);
    let out = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert!(!out.contains("**Tags:**"));
}

#[test]
fn markdown_multiple_findings_same_severity_grouped() {
    let findings = vec![
        finding("A", Severity::High),
        finding("B", Severity::High),
        finding("C", Severity::High),
    ];
    let out = secreport::render(&findings, Format::Markdown, "tool").unwrap();
    let group_start = out.find("## High Findings").unwrap();
    let group = &out[group_start..];
    assert!(group.contains("### A"));
    assert!(group.contains("### B"));
    assert!(group.contains("### C"));
}

#[test]
fn markdown_risk_summary_counts_exact() {
    let findings = vec![
        Finding::new("s", "t", Severity::Critical, "C1", "").unwrap(),
        Finding::new("s", "t", Severity::Critical, "C2", "").unwrap(),
        Finding::new("s", "t", Severity::High, "H1", "").unwrap(),
        Finding::new("s", "t", Severity::Medium, "M1", "").unwrap(),
        Finding::new("s", "t", Severity::Medium, "M2", "").unwrap(),
        Finding::new("s", "t", Severity::Medium, "M3", "").unwrap(),
        Finding::new("s", "t", Severity::Low, "L1", "").unwrap(),
        Finding::new("s", "t", Severity::Info, "I1", "").unwrap(),
        Finding::new("s", "t", Severity::Info, "I2", "").unwrap(),
    ];
    let out = secreport::render(&findings, Format::Markdown, "tool").unwrap();
    assert!(out.contains("| CRIT | 2 |"));
    assert!(out.contains("| HIGH | 1 |"));
    assert!(out.contains("| MED | 3 |"));
    assert!(out.contains("| LOW | 1 |"));
    assert!(out.contains("| INFO | 2 |"));
}

#[test]
fn markdown_large_number_of_findings() {
    let findings: Vec<Finding> = (0..5_000)
        .map(|i| finding(&format!("Finding-{}", i), Severity::Medium))
        .collect();
    let out = secreport::render(&findings, Format::Markdown, "tool").unwrap();
    assert!(out.contains("5\u{2009}000 findings") || out.contains("5000 findings"));
    assert!(out.contains("## Medium Findings"));
}