secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
//! Text (ANSI-colored terminal) output format tests.

mod common;

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

#[test]
fn text_empty_findings_message() {
    let out = secreport::render(&[] as &[Finding], Format::Text, "tool").unwrap();
    assert!(out.contains("No findings"));
}

#[test]
fn text_contains_colored_severity_labels() {
    for sev in [
        Severity::Critical,
        Severity::High,
        Severity::Medium,
        Severity::Low,
        Severity::Info,
    ] {
        let f = finding("T", sev);
        let out = secreport::render(&[f], Format::Text, "tool").unwrap();
        let label = sev.label();
        assert!(
            out.contains(label),
            "text output for {:?} should contain label {}",
            sev,
            label
        );
        assert!(out.contains('\x1b'), "text output should contain ANSI escapes");
    }
}

#[test]
fn text_strips_ansi_in_fields() {
    let f = Finding::new(
        "s\x1b[31mred\x1b[0m",
        "t\x1b[32mgreen\x1b[0m",
        Severity::High,
        "\x1b[1mTitle\x1b[0m",
        "\x1b[90mDetail\x1b[0m",
    )
    .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(!out.contains("\x1b[31m"));
    assert!(!out.contains("\x1b[32m"));
    assert!(out.contains("sred"));
    assert!(out.contains("tgreen"));
    assert!(out.contains("Title"));
    assert!(out.contains("Detail"));
}

#[test]
fn text_summary_counts_and_totals() {
    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::Text, "tool").unwrap();
    assert!(out.contains("  1 critical"));
    assert!(out.contains("  1 high"));
    assert!(out.contains("  1 medium"));
    assert!(out.contains("  1 low"));
    assert!(out.contains("  1 info"));
    assert!(out.contains("Total: \x1b[1m5\x1b[0m findings"));
}

#[test]
fn text_summary_by_scanner_sorted() {
    let findings = vec![
        Finding::new("z", "t", Severity::Info, "Z", "").unwrap(),
        Finding::new("a", "t", Severity::Info, "A", "").unwrap(),
        Finding::new("m", "t", Severity::Info, "M", "").unwrap(),
    ];
    let out = secreport::render(&findings, Format::Text, "tool").unwrap();
    let scanner_line_pos = out.find("By scanner:").unwrap();
    let scanner_line = &out[scanner_line_pos..];
    assert!(scanner_line.contains("a\x1b[0m:1"));
    assert!(scanner_line.contains("m\x1b[0m:1"));
    assert!(scanner_line.contains("z\x1b[0m:1"));
}

#[test]
fn text_summary_by_kind_sorted() {
    let findings = vec![
        Finding::builder("s", "t", Severity::Info)
            .title("Z")
            .kind(FindingKind::Vulnerability)
            .build()
            .unwrap(),
        Finding::builder("s", "t", Severity::Info)
            .title("A")
            .kind(FindingKind::Exposure)
            .build()
            .unwrap(),
    ];
    let out = secreport::render(&findings, Format::Text, "tool").unwrap();
    let kind_line_pos = out.find("By category:").unwrap();
    let kind_line = &out[kind_line_pos..];
    assert!(kind_line.contains("Exposure\x1b[0m:1"));
    assert!(kind_line.contains("Vulnerability\x1b[0m:1"));
}

#[test]
fn text_evidence_http_response() {
    let f = Finding::builder("s", "t", Severity::Medium)
        .title("T")
        .evidence(Evidence::http_status(200).unwrap())
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("HTTP 200"));
}

#[test]
fn text_evidence_dns_record() {
    let f = Finding::builder("s", "t", Severity::Medium)
        .title("T")
        .evidence(Evidence::DnsRecord {
            record_type: "A".into(),
            value: "1.2.3.4".into(),
        })
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("A"));
    assert!(out.contains("1.2.3.4"));
}

#[test]
fn text_evidence_banner() {
    let f = Finding::builder("s", "t", Severity::Medium)
        .title("T")
        .evidence(Evidence::Banner {
            raw: "banner text".into(),
        })
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("Banner:"));
    assert!(out.contains("banner text"));
}

#[test]
fn text_evidence_js_snippet() {
    let f = Finding::builder("s", "t", Severity::Medium)
        .title("T")
        .evidence(Evidence::JsSnippet {
            url: "https://example.com/app.js".into(),
            line: 42,
            snippet: "eval(...)".into(),
        })
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("app.js:42"));
    assert!(out.contains("eval(...)"));
}

#[test]
fn text_evidence_code_snippet() {
    let f = Finding::builder("s", "t", Severity::Medium)
        .title("T")
        .evidence(
            Evidence::code("src/main.rs", 10, "unsafe { ... }", Some(5), Some("rust".into()))
                .unwrap(),
        )
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("main.rs:10"));
    assert!(out.contains("unsafe { ... }"));
}

#[test]
fn text_evidence_banner_truncated_to_80_chars() {
    let raw = "x".repeat(200);
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .evidence(Evidence::Banner { raw: raw.clone().into() })
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    let line_start = out.find("Banner:").unwrap();
    let line = &out[line_start..];
    let line_end = line.find('\n').unwrap();
    let banner_line = &line[..line_end];
    assert!(
        banner_line.len() < 120,
        "banner line should be truncated in display"
    );
}

#[test]
fn text_exploit_hint_preview() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .exploit_hint("first line\nsecond line")
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("first line"));
    assert!(!out.contains("second line"));
}

#[test]
fn text_tags_prefixed_with_hash() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .tag("xss")
        .tag("web")
        .build()
        .unwrap();
    let out = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert!(out.contains("#xss"));
    assert!(out.contains("#web"));
}

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

#[test]
fn text_large_number_of_findings() {
    let findings: Vec<Finding> = (0..10_000)
        .map(|i| finding(&format!("F{}", i), Severity::Low))
        .collect();
    let out = secreport::render(&findings, Format::Text, "tool").unwrap();
    assert!(out.contains("Total: \x1b[1m10000\x1b[0m findings"));
}