secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
//! Full pipeline integration tests.

mod common;

use common::{finding, finding_full};
use secreport::{Format, emit, render, render_any};
use secfinding::{Evidence, Finding, FindingKind, Reportable, Severity};

#[test]
fn pipeline_json_end_to_end() {
    let findings = vec![
        finding_full("SQLi", Severity::Critical),
        finding_full("XSS", Severity::High),
        finding_full("Info", Severity::Info),
    ];
    let out = render(&findings, Format::Json, "pipeline-scanner").unwrap();
    let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
    assert_eq!(parsed.len(), 3);
    assert_eq!(parsed[0]["severity"], "critical");
    assert_eq!(parsed[1]["severity"], "high");
    assert_eq!(parsed[2]["severity"], "info");
}

#[test]
fn pipeline_sarif_end_to_end() {
    let findings = vec![
        finding("A", Severity::Critical),
        finding("B", Severity::High),
        finding("C", Severity::Medium),
    ];
    let out = render(&findings, Format::Sarif, "pipeline-scanner").unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
    assert_eq!(parsed["version"], "2.1.0");
    let results = parsed["runs"][0]["results"].as_array().unwrap();
    assert_eq!(results.len(), 3);
}

#[test]
fn pipeline_markdown_end_to_end() {
    let findings = vec![
        finding("Critical Finding", Severity::Critical),
        finding("High Finding", Severity::High),
    ];
    let out = render(&findings, Format::Markdown, "pipeline-scanner").unwrap();
    assert!(out.contains(r"# pipeline\-scanner Security Report"));
    assert!(out.contains("## Critical Findings"));
    assert!(out.contains("## High Findings"));
    assert!(out.contains("### Critical Finding"));
    assert!(out.contains("### High Finding"));
}

#[test]
fn pipeline_text_end_to_end() {
    let findings = vec![
        finding("F1", Severity::Critical),
        finding("F2", Severity::High),
        finding("F3", Severity::Medium),
    ];
    let out = render(&findings, Format::Text, "pipeline-scanner").unwrap();
    assert!(out.contains("CRIT"));
    assert!(out.contains("HIGH"));
    assert!(out.contains("MED"));
    assert!(out.contains("Total: \x1b[1m3\x1b[0m findings"));
}

#[test]
fn pipeline_jsonl_end_to_end() {
    let findings: Vec<Finding> = (0..100)
        .map(|i| finding(&format!("Finding-{}", i), Severity::Info))
        .collect();
    let out = render(&findings, Format::Jsonl, "pipeline-scanner").unwrap();
    let lines: Vec<&str> = out.lines().collect();
    assert_eq!(lines.len(), 100);
    for line in lines {
        let _: serde_json::Value = serde_json::from_str(line).unwrap();
    }
}

#[test]
fn pipeline_render_any_with_custom_type() {
    struct CustomFinding {
        scanner: &'static str,
        target: &'static str,
        title: &'static str,
        severity: Severity,
        detail: &'static str,
    }
    impl Reportable for CustomFinding {
        fn scanner(&self) -> &str { self.scanner }
        fn target(&self) -> &str { self.target }
        fn title(&self) -> &str { self.title }
        fn severity(&self) -> Severity { self.severity }
        fn detail(&self) -> &str { self.detail }
    }
    let findings = vec![
        CustomFinding {
            scanner: "custom",
            target: "https://custom.example.com",
            title: "Custom Issue",
            severity: Severity::High,
            detail: "Custom detail",
        },
    ];
    for format in [
        Format::Text,
        Format::Json,
        Format::Jsonl,
        Format::Sarif,
        Format::Markdown,
    ] {
        let out = render_any(&findings, format, "custom-tool").unwrap();
        assert!(!out.is_empty(), "{:?} produced empty output", format);
    }
}

#[test]
fn pipeline_emit_to_vec() {
    let mut buf = Vec::new();
    emit("hello world", &mut buf).unwrap();
    assert_eq!(String::from_utf8(buf).unwrap(), "hello world");
}

#[test]
fn pipeline_emit_with_unicode() {
    let mut buf = Vec::new();
    emit("日本語 🎉", &mut buf).unwrap();
    assert_eq!(String::from_utf8(buf).unwrap(), "日本語 🎉");
}

#[test]
fn pipeline_all_formats_empty_findings() {
    let empty: Vec<Finding> = vec![];
    for format in [
        Format::Text,
        Format::Json,
        Format::Jsonl,
        Format::Sarif,
        Format::Markdown,
    ] {
        let out = render(&empty, format, "tool").unwrap();
        if format == Format::Jsonl {
            assert_eq!(out, "");
        } else {
            assert!(!out.is_empty());
        }
    }
}

#[test]
fn pipeline_all_formats_single_finding() {
    let f = finding("Single", Severity::High);
    for format in [
        Format::Text,
        Format::Json,
        Format::Jsonl,
        Format::Sarif,
        Format::Markdown,
    ] {
        let out = render(&[f.clone()], format, "tool").unwrap();
        assert!(!out.is_empty());
    }
}

#[test]
fn pipeline_mixed_severity_counts_accurate() {
    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 md = render(&findings, Format::Markdown, "tool").unwrap();
    assert!(md.contains("| CRIT | 2 |"));
    assert!(md.contains("| HIGH | 1 |"));
    assert!(md.contains("| MED | 3 |"));
    assert!(md.contains("| LOW | 1 |"));
    assert!(md.contains("| INFO | 2 |"));

    let txt = render(&findings, Format::Text, "tool").unwrap();
    assert!(txt.contains("  2 critical"));
    assert!(txt.contains("  1 high"));
    assert!(txt.contains("  3 medium"));
    assert!(txt.contains("  1 low"));
    assert!(txt.contains("  2 info"));
}

#[test]
fn pipeline_format_from_str_loose_all_variants() {
    assert_eq!(Format::from_str_loose("text"), Some(Format::Text));
    assert_eq!(Format::from_str_loose("json"), Some(Format::Json));
    assert_eq!(Format::from_str_loose("jsonl"), Some(Format::Jsonl));
    assert_eq!(Format::from_str_loose("sarif"), Some(Format::Sarif));
    assert_eq!(Format::from_str_loose("markdown"), Some(Format::Markdown));
    assert_eq!(Format::from_str_loose("md"), Some(Format::Markdown));
}

#[test]
fn pipeline_format_from_str_loose_case_insensitive() {
    assert_eq!(Format::from_str_loose("TEXT"), Some(Format::Text));
    assert_eq!(Format::from_str_loose("Json"), Some(Format::Json));
    assert_eq!(Format::from_str_loose("JSONL"), Some(Format::Jsonl));
    assert_eq!(Format::from_str_loose("SarIf"), Some(Format::Sarif));
    assert_eq!(Format::from_str_loose("MarkDown"), Some(Format::Markdown));
    assert_eq!(Format::from_str_loose("MD"), Some(Format::Markdown));
}

#[test]
fn pipeline_format_from_str_loose_unknown_returns_none() {
    assert_eq!(Format::from_str_loose(""), None);
    assert_eq!(Format::from_str_loose("xml"), None);
    assert_eq!(Format::from_str_loose("jsonlines"), None);
    assert_eq!(Format::from_str_loose("sarif2.1"), None);
}

#[test]
fn pipeline_format_display() {
    assert_eq!(Format::Text.to_string(), "text");
    assert_eq!(Format::Json.to_string(), "json");
    assert_eq!(Format::Jsonl.to_string(), "jsonl");
    assert_eq!(Format::Sarif.to_string(), "sarif");
    assert_eq!(Format::Markdown.to_string(), "markdown");
}

#[test]
fn pipeline_generic_finding_builder_defaults() {
    let gf = secreport::models::GenericFinding::builder("scanner-a", "https://target.com", Severity::High).build();
    assert_eq!(gf.scanner, "scanner-a");
    assert_eq!(gf.target, "https://target.com");
    assert_eq!(gf.severity, Severity::High);
    assert_eq!(gf.title, "");
    assert_eq!(gf.detail, "");
    assert!(gf.cwe_ids.is_empty());
    assert!(gf.cve_ids.is_empty());
    assert!(gf.tags.is_empty());
    assert_eq!(gf.confidence, None);
    assert_eq!(gf.rule_id, "");
    assert_eq!(gf.sarif_level, Severity::High.sarif_level());
    assert_eq!(gf.exploit_hint, None);
    assert!(gf.evidence.is_empty());
    assert_eq!(gf.kind, FindingKind::Unclassified);
}

#[test]
fn pipeline_generic_finding_builder_chains_all_fields() {
    let cwe_ids: Vec<std::sync::Arc<str>> = vec!["CWE-79".into(), "CWE-89".into()];
    let cve_ids: Vec<std::sync::Arc<str>> = vec!["CVE-2024-1".into()];
    let tags: Vec<std::sync::Arc<str>> = vec!["xss".into(), "web".into()];
    let evidence = vec![Evidence::http_status(500).unwrap()];

    let gf = secreport::models::GenericFinding::builder("sc", "tgt", Severity::Critical)
        .title("XSS")
        .detail("Reflected XSS")
        .cwe_ids(&cwe_ids)
        .cve_ids(&cve_ids)
        .tags(&tags)
        .confidence(Some(0.95))
        .rule_id("RULE-001")
        .sarif_level("error")
        .exploit_hint(Some("curl ..."))
        .evidence(&evidence)
        .kind(FindingKind::Vulnerability)
        .build();

    assert_eq!(gf.scanner, "sc");
    assert_eq!(gf.target, "tgt");
    assert_eq!(gf.severity, Severity::Critical);
    assert_eq!(gf.title, "XSS");
    assert_eq!(gf.detail, "Reflected XSS");
    assert_eq!(gf.cwe_ids, &cwe_ids[..]);
    assert_eq!(gf.cve_ids, &cve_ids[..]);
    assert_eq!(gf.tags, &tags[..]);
    assert_eq!(gf.confidence, Some(0.95));
    assert_eq!(gf.rule_id, "RULE-001");
    assert_eq!(gf.sarif_level, "error");
    assert_eq!(gf.exploit_hint, Some("curl ..."));
    assert_eq!(gf.evidence, &evidence[..]);
    assert_eq!(gf.kind, FindingKind::Vulnerability);
}