use std::io;
use crate::{emit, render, render_any, Format};
use crate::models::GenericFinding;
use secfinding::{Evidence, Finding, FindingKind, Reportable, Severity};
#[test]
fn 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 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 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 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 format_serde_roundtrip() {
for format in [
Format::Text,
Format::Json,
Format::Jsonl,
Format::Sarif,
Format::Markdown,
] {
let json = serde_json::to_string(&format).unwrap();
let back: Format = serde_json::from_str(&json).unwrap();
assert_eq!(back, format, "serde roundtrip failed for {:?}", format);
}
}
#[test]
fn format_serde_deserialize_from_strings() {
let pairs = [
("\"Text\"", Format::Text),
("\"Json\"", Format::Json),
("\"Jsonl\"", Format::Jsonl),
("\"Sarif\"", Format::Sarif),
("\"Markdown\"", Format::Markdown),
];
for (json, expected) in pairs {
let back: Format = serde_json::from_str(json).unwrap();
assert_eq!(back, expected);
}
}
#[test]
fn generic_finding_builder_defaults() {
let gf = 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 generic_finding_builder_chains_all_fields() {
let cwe_ids: Vec<std::sync::Arc<str>> = vec![std::sync::Arc::from("CWE-79"), std::sync::Arc::from("CWE-89")];
let cve_ids: Vec<std::sync::Arc<str>> = vec![std::sync::Arc::from("CVE-2024-1")];
let tags: Vec<std::sync::Arc<str>> = vec![std::sync::Arc::from("xss"), std::sync::Arc::from("web")];
let evidence = vec![Evidence::http_status(500).unwrap()];
let gf = 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);
}
#[test]
fn generic_finding_builder_rule_id_accepts_numbers() {
let gf = GenericFinding::builder("s", "t", Severity::Low)
.rule_id("12345")
.build();
assert_eq!(gf.rule_id, "12345");
}
#[test]
fn generic_finding_builder_rule_id_accepts_string() {
let gf = GenericFinding::builder("s", "t", Severity::Low)
.rule_id("my-rule")
.build();
assert_eq!(gf.rule_id, "my-rule");
}
struct GoodReportable {
scanner: String,
target: String,
title: String,
detail: String,
severity: Severity,
confidence: Option<f64>,
}
impl Reportable for GoodReportable {
fn scanner(&self) -> &str {
&self.scanner
}
fn target(&self) -> &str {
&self.target
}
fn severity(&self) -> Severity {
self.severity
}
fn title(&self) -> &str {
&self.title
}
fn detail(&self) -> &str {
&self.detail
}
fn confidence(&self) -> Option<f64> {
self.confidence
}
}
#[test]
fn generic_finding_try_from_reportable_success() {
let r = GoodReportable {
scanner: "scan".into(),
target: "tgt".into(),
title: "title".into(),
detail: "detail".into(),
severity: Severity::Medium,
confidence: Some(0.5),
};
let gf = GenericFinding::try_from_reportable(&r).unwrap();
assert_eq!(gf.scanner, "scan");
assert_eq!(gf.target, "tgt");
assert_eq!(gf.title, "title");
assert_eq!(gf.detail, "detail");
assert_eq!(gf.severity, Severity::Medium);
assert_eq!(gf.confidence, Some(0.5));
}
#[test]
fn generic_finding_try_from_reportable_rejects_nan_confidence() {
let r = GoodReportable {
scanner: "scan".into(),
target: "tgt".into(),
title: "title".into(),
detail: "detail".into(),
severity: Severity::Medium,
confidence: Some(f64::NAN),
};
let err = GenericFinding::try_from_reportable(&r).unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[test]
fn generic_finding_try_from_reportable_rejects_infinite_confidence() {
let r = GoodReportable {
scanner: "scan".into(),
target: "tgt".into(),
title: "title".into(),
detail: "detail".into(),
severity: Severity::Medium,
confidence: Some(f64::INFINITY),
};
let err = GenericFinding::try_from_reportable(&r).unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[test]
fn generic_finding_try_from_reportable_rejects_neg_infinite_confidence() {
let r = GoodReportable {
scanner: "scan".into(),
target: "tgt".into(),
title: "title".into(),
detail: "detail".into(),
severity: Severity::Medium,
confidence: Some(f64::NEG_INFINITY),
};
let err = GenericFinding::try_from_reportable(&r).unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[test]
fn generic_finding_json_value_structure() {
let gf = GenericFinding::builder("s", "t", Severity::High)
.title("T")
.detail("D")
.rule_id("R1")
.build();
let v = gf.json_value();
assert_eq!(v["scanner"], "s");
assert_eq!(v["target"], "t");
assert_eq!(v["severity"], "high");
assert_eq!(v["title"], "T");
assert_eq!(v["detail"], "D");
assert_eq!(v["rule_id"], "R1");
assert!(v["cwe_ids"].is_array());
assert!(v["cve_ids"].is_array());
assert!(v["tags"].is_array());
assert_eq!(v["confidence"], serde_json::Value::Null);
assert_eq!(v["exploit_hint"], serde_json::Value::Null);
assert_eq!(v["kind"], "Unclassified");
}
#[test]
fn generic_finding_json_value_roundtrip() {
let evidence = vec![Evidence::http_status(404).unwrap()];
let cwe = vec![std::sync::Arc::from("CWE-1")];
let cve = vec![std::sync::Arc::from("CVE-2024-1")];
let tags = vec![std::sync::Arc::from("tag1")];
let gf = GenericFinding::builder("s", "t", Severity::Critical)
.title("T")
.detail("D")
.cwe_ids(&cwe)
.cve_ids(&cve)
.tags(&tags)
.confidence(Some(0.99))
.rule_id("R")
.sarif_level("error")
.exploit_hint(Some("hint"))
.evidence(&evidence)
.kind(FindingKind::Vulnerability)
.build();
let v = gf.json_value();
let json = serde_json::to_string(&v).unwrap();
let back: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(back["scanner"], "s");
assert_eq!(back["severity"], "critical");
assert_eq!(back["confidence"], 0.99);
assert_eq!(back["kind"], "Vulnerability");
assert_eq!(back["exploit_hint"], "hint");
}
#[test]
fn render_text_basic() {
let f = Finding::new("s", "t", Severity::High, "Title", "Detail").unwrap();
let out = render(&[f], Format::Text, "tool").unwrap();
assert!(out.contains("HIGH"));
assert!(out.contains("Title"));
assert!(out.contains("Detail"));
assert!(out.contains("t"));
}
#[test]
fn render_json_basic() {
let f = Finding::new("s", "t", Severity::Medium, "Title", "Detail").unwrap();
let out = render(&[f], Format::Json, "tool").unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0]["title"], "Title");
assert_eq!(parsed[0]["severity"], "medium");
}
#[test]
fn render_jsonl_basic() {
let findings = vec![
Finding::new("s", "t1", Severity::Low, "A", "").unwrap(),
Finding::new("s", "t2", Severity::High, "B", "").unwrap(),
];
let out = render(&findings, Format::Jsonl, "tool").unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
for line in &lines {
let _: serde_json::Value = serde_json::from_str(line).unwrap();
}
}
#[test]
fn render_sarif_basic() {
let f = Finding::new("s", "t", Severity::Critical, "Title", "Detail").unwrap();
let out = render(&[f], Format::Sarif, "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["version"], "2.1.0");
assert_eq!(
parsed["$schema"],
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
);
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["level"], "error");
}
#[test]
fn verify_sarif_precision_levels() {
use crate::render::json::render_sarif_generic;
use crate::models::GenericFinding;
let g_high = GenericFinding::builder("s", "t", Severity::High)
.title("T")
.detail("D")
.rule_id("R")
.confidence(Some(0.95))
.build();
let out = render_sarif_generic(&[g_high], "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let rules = parsed["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
assert_eq!(rules[0]["properties"]["precision"], "high");
let g_med = GenericFinding::builder("s", "t", Severity::High)
.title("T")
.detail("D")
.rule_id("R2")
.confidence(Some(0.6))
.build();
let out2 = render_sarif_generic(&[g_med], "tool").unwrap();
let parsed2: serde_json::Value = serde_json::from_str(&out2).unwrap();
let rules2 = parsed2["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
assert_eq!(rules2[0]["properties"]["precision"], "medium");
let g_low = GenericFinding::builder("s", "t", Severity::High)
.title("T")
.detail("D")
.rule_id("R3")
.confidence(Some(0.2))
.build();
let out3 = render_sarif_generic(&[g_low], "tool").unwrap();
let parsed3: serde_json::Value = serde_json::from_str(&out3).unwrap();
let rules3 = parsed3["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
assert_eq!(rules3[0]["properties"]["precision"], "low");
}
#[test]
fn verify_markdown_multiple_targets() {
use crate::render::markdown::render_markdown_generic;
use crate::models::GenericFinding;
let g1 = GenericFinding::builder("s", "t1", Severity::Low)
.title("A")
.build();
let g2 = GenericFinding::builder("s", "t2", Severity::Low)
.title("B")
.build();
let md = render_markdown_generic(&[g1, g2], "tool");
assert!(md.contains("multiple targets"));
}
#[test]
fn verify_sarif_ruleid_linking() {
use crate::render::json::render_sarif_generic;
use crate::models::GenericFinding;
let g = GenericFinding::builder("s", "t", Severity::High)
.title("My Title")
.detail("D")
.rule_id("")
.build();
let out = render_sarif_generic(&[g], "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["ruleId"], "s/my-title");
}
#[test]
fn render_markdown_basic() {
let f = Finding::new("s", "t", Severity::Info, "Title", "Detail").unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.starts_with("# "));
assert!(out.contains("Title"));
assert!(out.contains("Detail"));
assert!(out.contains("Informational"));
}
struct MinimalReportable {
scanner: &'static str,
target: &'static str,
title: &'static str,
severity: Severity,
}
impl Reportable for MinimalReportable {
fn scanner(&self) -> &str {
self.scanner
}
fn target(&self) -> &str {
self.target
}
fn title(&self) -> &str {
self.title
}
fn severity(&self) -> Severity {
self.severity
}
}
#[test]
fn render_any_json_custom_reportable() {
let r = MinimalReportable {
scanner: "custom",
target: "https://example.com",
title: "Custom Finding",
severity: Severity::High,
};
let out = render_any(&[r], Format::Json, "tool").unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0]["scanner"], "custom");
}
#[test]
fn render_any_text_custom_reportable() {
let r = MinimalReportable {
scanner: "custom",
target: "tgt",
title: "T",
severity: Severity::Low,
};
let out = render_any(&[r], Format::Text, "tool").unwrap();
assert!(out.contains("LOW"));
assert!(out.contains("T"));
}
#[test]
fn render_any_sarif_custom_reportable() {
let r = MinimalReportable {
scanner: "custom",
target: "tgt",
title: "T",
severity: Severity::Medium,
};
let out = render_any(&[r], Format::Sarif, "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn render_any_markdown_custom_reportable() {
let r = MinimalReportable {
scanner: "custom",
target: "tgt",
title: "T",
severity: Severity::Critical,
};
let out = render_any(&[r], Format::Markdown, "tool").unwrap();
assert!(out.contains("Critical Findings"));
assert!(out.contains("T"));
}
#[test]
fn render_any_jsonl_custom_reportable() {
let items: Vec<MinimalReportable> = (0..3)
.map(|i| MinimalReportable {
scanner: "c",
target: "t",
title: if i == 0 { "A" } else { "B" },
severity: Severity::Info,
})
.collect();
let out = render_any(&items, Format::Jsonl, "tool").unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
}
#[test]
fn 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 emit_to_vec_with_unicode() {
let mut buf = Vec::new();
emit("日本語 🎉", &mut buf).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "日本語 🎉");
}
struct FaultyWriter {
budget: usize,
written: usize,
}
impl io::Write for FaultyWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.written + buf.len() > self.budget {
return Err(io::Error::new(io::ErrorKind::Other, "boom"));
}
self.written += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn emit_faulty_writer_errors() {
let mut writer = FaultyWriter {
budget: 2,
written: 0,
};
let result = emit("hello", &mut writer);
assert!(result.is_err());
}
#[test]
fn emit_exact_budget_writer_succeeds() {
let mut writer = FaultyWriter {
budget: 5,
written: 0,
};
let result = emit("hello", &mut writer);
assert!(result.is_ok());
assert_eq!(writer.written, 5);
}
#[test]
fn emit_to_temp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.txt");
let mut file = std::fs::File::create(&path).unwrap();
emit("persisted", &mut file).unwrap();
drop(file);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "persisted");
}
#[test]
fn render_empty_json_is_empty_array() {
let out = render(&[] as &[Finding], Format::Json, "tool").unwrap();
assert_eq!(out.trim(), "[]");
}
#[test]
fn render_empty_jsonl_is_empty_string() {
let out = render(&[] as &[Finding], Format::Jsonl, "tool").unwrap();
assert_eq!(out, "");
}
#[test]
fn render_empty_text_contains_no_findings() {
let out = render(&[] as &[Finding], Format::Text, "tool").unwrap();
assert!(out.contains("No findings"));
}
#[test]
fn render_empty_sarif_has_empty_results() {
let out = render(&[] as &[Finding], Format::Sarif, "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert!(results.is_empty());
}
#[test]
fn render_empty_markdown_has_zero_findings() {
let out = render(&[] as &[Finding], Format::Markdown, "tool").unwrap();
assert!(out.contains("0 findings"));
assert!(!out.contains("Critical Findings"));
}
#[test]
fn render_single_finding_all_formats() {
let f = Finding::new("s", "t", Severity::High, "Title", "Detail").unwrap();
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(), "{:?} produced empty output", format);
}
}
#[test]
fn render_text_with_all_evidence_types() {
let finding = Finding::builder("s", "t", Severity::Medium)
.title("T")
.evidence(Evidence::HttpResponse {
status: 200,
headers: vec![],
body_excerpt: None,
})
.evidence(Evidence::DnsRecord {
record_type: "A".into(),
value: "1.2.3.4".into(),
})
.evidence(Evidence::Banner {
raw: "banner text".into(),
})
.evidence(Evidence::JsSnippet {
url: "https://example.com/app.js".into(),
line: 42,
snippet: "eval(...)".into(),
})
.evidence(Evidence::CodeSnippet {
file: "src/main.rs".into(),
line: 10,
column: Some(5),
snippet: "unsafe { ... }".into(),
language: Some("rust".into()),
})
.evidence(Evidence::HttpRequest {
method: "GET".into(),
url: "https://example.com".into(),
headers: vec![],
body: None,
})
.evidence(Evidence::PatternMatch {
pattern: "p".into(),
matched: "m".into(),
})
.evidence(Evidence::Raw("raw evidence".into()))
.build()
.unwrap();
let out = render(&[finding], Format::Text, "tool").unwrap();
assert!(out.contains("HTTP 200"));
assert!(out.contains("A"));
assert!(out.contains("banner text"));
assert!(out.contains("app.js:42"));
assert!(out.contains("eval(...)"));
assert!(out.contains("main.rs:10"));
assert!(out.contains("unsafe { ... }"));
}