secreport 0.3.0

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

mod common;

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

#[test]
fn json_empty_is_empty_array() {
    let parsed = render_json(&[]);
    assert!(parsed.is_empty());
}

#[test]
fn json_single_finding_has_all_expected_fields() {
    let f = finding_full("SQL Injection", Severity::Critical);
    let parsed = render_json(&[f]);
    assert_eq!(parsed.len(), 1);
    let item = &parsed[0];
    assert_eq!(item["scanner"], "test-scanner");
    assert_eq!(item["target"], "https://example.com");
    assert_eq!(item["severity"], "critical");
    assert_eq!(item["title"], "SQL Injection");
    assert_eq!(item["detail"], "Detailed description of the issue.");
    assert_eq!(item["rule_id"], "test-scanner/sql-injection");
    assert!(item["cwe_ids"].is_array());
    assert!(item["cve_ids"].is_array());
    assert!(item["tags"].is_array());
    assert_eq!(item["confidence"], 0.95);
    assert_eq!(item["exploit_hint"], "curl -X GET https://example.com/poc");
    assert_eq!(item["kind"], "Vulnerability");
}

#[test]
fn json_multiple_findings_preserve_order() {
    let findings: Vec<Finding> = (0..10)
        .map(|i| finding(&format!("Finding-{}", i), Severity::Medium))
        .collect();
    let parsed = render_json(&findings);
    assert_eq!(parsed.len(), 10);
    for (i, item) in parsed.iter().enumerate() {
        assert_eq!(item["title"], format!("Finding-{}", i));
    }
}

#[test]
fn json_severity_levels_encoded_correctly() {
    let findings = vec![
        finding("A", Severity::Info),
        finding("B", Severity::Low),
        finding("C", Severity::Medium),
        finding("D", Severity::High),
        finding("E", Severity::Critical),
    ];
    let parsed = render_json(&findings);
    let expected = vec!["info", "low", "medium", "high", "critical"];
    for (i, exp) in expected.iter().enumerate() {
        assert_eq!(parsed[i]["severity"], *exp);
    }
}

#[test]
fn json_evidence_array_contains_serialized_evidence() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .evidence(Evidence::http_status(404).unwrap())
        .evidence(Evidence::DnsRecord {
            record_type: "A".into(),
            value: "1.2.3.4".into(),
        })
        .build()
        .unwrap();
    let parsed = render_json(&[f]);
    let ev = parsed[0]["evidence"].as_array().unwrap();
    assert_eq!(ev.len(), 2);
    assert_eq!(ev[0]["type"], "http_response");
    assert_eq!(ev[0]["status"], 404);
    assert_eq!(ev[1]["type"], "dns_record");
    assert_eq!(ev[1]["record_type"], "A");
}

#[test]
fn json_null_fields_for_missing_optionals() {
    let f = finding("Minimal", Severity::Low);
    let parsed = render_json(&[f]);
    assert_eq!(parsed[0]["confidence"], serde_json::Value::Null);
    assert_eq!(parsed[0]["exploit_hint"], serde_json::Value::Null);
}

#[test]
fn json_output_is_pretty_printed() {
    let f = finding("T", Severity::High);
    let out = secreport::render(&[f], Format::Json, "tool").unwrap();
    assert!(out.contains('\n'), "pretty-printed JSON should contain newlines");
    assert!(out.contains("  "), "pretty-printed JSON should contain indentation");
}

#[test]
fn json_finding_kind_variants() {
    for kind in [
        FindingKind::Vulnerability,
        FindingKind::Misconfiguration,
        FindingKind::Exposure,
        FindingKind::TechDetect,
        FindingKind::DefaultCredentials,
        FindingKind::InfoDisclosure,
        FindingKind::FileDiscovery,
        FindingKind::SecretLeak,
        FindingKind::MaliciousCode,
        FindingKind::SupplyChain,
        FindingKind::Unclassified,
        FindingKind::Other,
    ] {
        let f = Finding::builder("s", "t", Severity::Medium)
            .title("T")
            .kind(kind)
            .build()
            .unwrap();
        let parsed = render_json(&[f]);
        assert_eq!(parsed[0]["kind"], format!("{:?}", kind));
    }
}

#[test]
fn json_unicode_preserved() {
    let f = Finding::new("スキャナ", "https://例え.jp", Severity::High, "日本語タイトル 🚨", "詳細").unwrap();
    let out = secreport::render(&[f], Format::Json, "tool").unwrap();
    assert!(out.contains("日本語タイトル 🚨"));
    assert!(out.contains("詳細"));
    assert!(out.contains("スキャナ"));
}

#[test]
fn json_control_characters_escaped() {
    let f = Finding::new("s", "t", Severity::Medium, "a\x00b", "c\x01d").unwrap();
    let out = secreport::render(&[f], Format::Json, "tool").unwrap();
    assert!(out.contains("\\u0000"));
    assert!(out.contains("\\u0001"));
}

#[test]
fn json_large_number_of_findings() {
    let findings: Vec<Finding> = (0..5_000)
        .map(|i| finding(&format!("Finding-{}", i), Severity::Info))
        .collect();
    let parsed = render_json(&findings);
    assert_eq!(parsed.len(), 5_000);
}

#[test]
fn json_rule_id_present_when_set_via_builder() {
    // Note: Finding does not expose rule_id builder; rule_id comes from Reportable default.
    // The json_value from GenericFinding would contain rule_id if set.
    // Since render goes through GenericFinding via Reportable, rule_id uses Reportable::rule_id().
    let f = Finding::new("my-scanner", "t", Severity::High, "Test Title", "d").unwrap();
    let parsed = render_json(&[f]);
    assert_eq!(parsed[0]["rule_id"], "my-scanner/test-title");
}