use sbom_tools::watch::{WatchConfig, parse_duration};
use std::path::PathBuf;
use std::time::Duration;
#[test]
fn test_parse_duration_seconds() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
}
#[test]
fn test_parse_duration_days() {
assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
}
#[test]
fn test_parse_duration_milliseconds() {
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
}
#[test]
fn test_parse_duration_invalid() {
assert!(parse_duration("abc").is_err());
assert!(parse_duration("").is_err());
assert!(parse_duration("10").is_err());
assert!(parse_duration("10x").is_err());
}
#[test]
fn test_watch_loop_no_files_returns_error() {
let dir = tempfile::tempdir().expect("create temp dir");
let config = WatchConfig {
watch_dirs: vec![dir.path().to_path_buf()],
poll_interval: Duration::from_secs(1),
enrich_interval: Duration::from_secs(3600),
debounce: Duration::ZERO,
output: sbom_tools::config::OutputConfig::default(),
enrichment: sbom_tools::config::EnrichmentConfig::default(),
webhook_url: None,
exit_on_change: false,
max_snapshots: 10,
quiet: true,
dry_run: false,
cra_standards_enabled: false,
cra_standards_interval: Duration::from_secs(86_400),
cra_standards_timeout: Duration::from_secs(10),
};
let result = sbom_tools::watch::run_watch_loop(&config);
assert!(result.is_err());
let err_msg = result.err().unwrap().to_string();
assert!(
err_msg.contains("no SBOM files found"),
"expected NoFilesFound, got: {err_msg}"
);
}
#[test]
fn test_watch_loop_nonexistent_dir() {
let config = WatchConfig {
watch_dirs: vec![PathBuf::from("/nonexistent/dir/that/does/not/exist")],
poll_interval: Duration::from_secs(1),
enrich_interval: Duration::from_secs(3600),
debounce: Duration::ZERO,
output: sbom_tools::config::OutputConfig::default(),
enrichment: sbom_tools::config::EnrichmentConfig::default(),
webhook_url: None,
exit_on_change: false,
max_snapshots: 10,
quiet: true,
dry_run: false,
cra_standards_enabled: false,
cra_standards_interval: Duration::from_secs(86_400),
cra_standards_timeout: Duration::from_secs(10),
};
let result = sbom_tools::watch::run_watch_loop(&config);
assert!(result.is_err());
}
#[test]
fn test_watch_loop_exit_on_change() {
let dir = tempfile::tempdir().expect("create temp dir");
let fixture_path = dir.path().join("test.cdx.json");
let demo = std::fs::read_to_string("tests/fixtures/demo-old.cdx.json").expect("read fixture");
std::fs::write(&fixture_path, &demo).expect("write fixture");
let config = WatchConfig {
watch_dirs: vec![dir.path().to_path_buf()],
poll_interval: Duration::from_millis(50),
enrich_interval: Duration::from_secs(3600),
debounce: Duration::ZERO,
output: sbom_tools::config::OutputConfig::default(),
enrichment: sbom_tools::config::EnrichmentConfig::default(),
webhook_url: None,
exit_on_change: true,
max_snapshots: 10,
quiet: true,
dry_run: false,
cra_standards_enabled: false,
cra_standards_interval: Duration::from_secs(86_400),
cra_standards_timeout: Duration::from_secs(10),
};
let config_clone = config.clone();
let fixture_clone = fixture_path.clone();
let handle = std::thread::spawn(move || sbom_tools::watch::run_watch_loop(&config_clone));
std::thread::sleep(Duration::from_millis(100));
let demo_new =
std::fs::read_to_string("tests/fixtures/demo-new.cdx.json").expect("read new fixture");
std::fs::write(&fixture_clone, &demo_new).expect("modify fixture");
let result = handle.join().expect("thread join");
assert!(result.is_ok(), "watch loop should exit cleanly: {result:?}");
}
#[test]
fn test_watch_loop_initial_scan_parses_fixtures() {
let dir = tempfile::tempdir().expect("create temp dir");
let cdx = std::fs::read_to_string("tests/fixtures/demo-old.cdx.json").expect("read fixture");
std::fs::write(dir.path().join("app.cdx.json"), &cdx).expect("write");
let spdx_dir = PathBuf::from("tests/fixtures/spdx");
if spdx_dir.exists()
&& let Some(Ok(entry)) = std::fs::read_dir(&spdx_dir).ok().and_then(|mut entries| {
entries.find(|e| {
e.as_ref().is_ok_and(|e| {
e.file_name()
.to_string_lossy()
.to_lowercase()
.ends_with(".spdx.json")
})
})
})
{
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
if !content.is_empty() {
std::fs::write(dir.path().join("lib.spdx.json"), content).expect("write spdx");
}
}
let config = WatchConfig {
watch_dirs: vec![dir.path().to_path_buf()],
poll_interval: Duration::from_millis(50),
enrich_interval: Duration::from_secs(3600),
debounce: Duration::ZERO,
output: sbom_tools::config::OutputConfig::default(),
enrichment: sbom_tools::config::EnrichmentConfig::default(),
webhook_url: None,
exit_on_change: true,
max_snapshots: 10,
quiet: true,
dry_run: false,
cra_standards_enabled: false,
cra_standards_interval: Duration::from_secs(86_400),
cra_standards_timeout: Duration::from_secs(10),
};
let config_clone = config.clone();
let dir_path = dir.path().to_path_buf();
let handle = std::thread::spawn(move || sbom_tools::watch::run_watch_loop(&config_clone));
std::thread::sleep(Duration::from_millis(100));
let cdx_new =
std::fs::read_to_string("tests/fixtures/demo-new.cdx.json").expect("read new fixture");
std::fs::write(dir_path.join("app.cdx.json"), &cdx_new).expect("modify");
let result = handle.join().expect("thread join");
assert!(result.is_ok());
}
#[cfg(feature = "enrichment")]
mod enrichment_loop {
use super::*;
use httpmock::prelude::*;
const VULN_ID: &str = "GHSA-watch-test-0001";
fn sbom_body(version: &str) -> String {
serde_json::json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"components": [{
"type": "library",
"name": "lodash",
"version": version,
"purl": format!("pkg:npm/lodash@{version}")
}]
})
.to_string()
}
fn full_vuln_body() -> serde_json::Value {
serde_json::json!({
"id": VULN_ID,
"summary": "Prototype pollution in lodash",
"modified": "2026-01-10T00:00:00Z",
"severity": [
{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}
]
})
}
#[test]
fn test_watch_loop_enriched_reparse_no_false_resolved_alerts() {
let server = MockServer::start();
let health_mock = server.mock(|when, then| {
when.method(GET).path("/v1/vulns/OSV-2020-1");
then.status(404);
});
let batch_mock = server.mock(|when, then| {
when.method(POST).path("/v1/querybatch");
then.status(200).json_body(serde_json::json!({
"results": [{"vulns": [{"id": VULN_ID, "modified": "2026-01-10T00:00:00Z"}]}]
}));
});
let vuln_mock = server.mock(|when, then| {
when.method(GET).path(format!("/v1/vulns/{VULN_ID}"));
then.status(200).json_body(full_vuln_body());
});
let watch_dir = tempfile::tempdir().expect("create watch dir");
let cache_dir = tempfile::tempdir().expect("create cache dir");
let out_dir = tempfile::tempdir().expect("create out dir");
let sbom_path = watch_dir.path().join("app.cdx.json");
std::fs::write(&sbom_path, sbom_body("4.17.20")).expect("write sbom");
let output_file = out_dir.path().join("events.ndjson");
let config = WatchConfig {
watch_dirs: vec![watch_dir.path().to_path_buf()],
poll_interval: Duration::from_millis(50),
enrich_interval: Duration::from_millis(150),
debounce: Duration::ZERO,
output: sbom_tools::config::OutputConfig {
format: sbom_tools::reports::ReportFormat::Json,
file: Some(output_file.clone()),
..Default::default()
},
enrichment: sbom_tools::config::EnrichmentConfig {
enabled: true,
cache_dir: Some(cache_dir.path().to_path_buf()),
timeout_secs: 5,
api_base: Some(server.base_url()),
..Default::default()
},
webhook_url: None,
exit_on_change: true,
max_snapshots: 10,
quiet: true,
dry_run: false,
cra_standards_enabled: false,
cra_standards_interval: Duration::from_secs(86_400),
cra_standards_timeout: Duration::from_secs(10),
};
let config_clone = config.clone();
let handle = std::thread::spawn(move || sbom_tools::watch::run_watch_loop(&config_clone));
std::thread::sleep(Duration::from_millis(500));
std::fs::write(&sbom_path, sbom_body("4.17.21")).expect("modify sbom");
let result = handle.join().expect("thread join");
assert!(result.is_ok(), "watch loop should exit cleanly: {result:?}");
assert!(
batch_mock.hits() >= 3,
"expected >=3 querybatch calls, got {}",
batch_mock.hits()
);
assert!(vuln_mock.hits() >= 1);
assert!(health_mock.hits() >= 3);
let output = std::fs::read_to_string(&output_file).expect("read ndjson output");
let events: Vec<serde_json::Value> = output
.lines()
.map(|l| serde_json::from_str(l).expect("valid NDJSON line"))
.collect();
let changes: Vec<&serde_json::Value> =
events.iter().filter(|e| e["type"] == "change").collect();
assert!(!changes.is_empty(), "expected a change event: {events:?}");
for change in &changes {
assert_eq!(
change["resolved_vulns"].as_array().map(Vec::len),
Some(0),
"vuln still present must not be alerted as resolved: {change}"
);
assert_eq!(
change["new_vulns"].as_array().map(Vec::len),
Some(0),
"known vuln must not be re-alerted as new: {change}"
);
}
assert!(
!events.iter().any(|e| e["type"] == "new_vulns"),
"enrichment cycles must not re-announce known vulns: {events:?}"
);
let statuses: Vec<&serde_json::Value> =
events.iter().filter(|e| e["type"] == "status").collect();
assert!(!statuses.is_empty(), "expected status events: {events:?}");
for status in &statuses {
assert_eq!(
status["vulns"], 1,
"vuln count must stay at 1 across enrichment cycles: {status}"
);
}
}
}
#[test]
fn test_watch_ndjson_output_produces_valid_json() {
use sbom_tools::reports::ReportFormat;
let dir = tempfile::tempdir().expect("create temp dir");
let output_file = dir.path().join("events.ndjson");
let demo = std::fs::read_to_string("tests/fixtures/demo-old.cdx.json").expect("read fixture");
let fixture_path = dir.path().join("test.cdx.json");
std::fs::write(&fixture_path, &demo).expect("write fixture");
let config = WatchConfig {
watch_dirs: vec![dir.path().to_path_buf()],
poll_interval: Duration::from_millis(50),
enrich_interval: Duration::from_secs(3600),
debounce: Duration::ZERO,
output: sbom_tools::config::OutputConfig {
format: ReportFormat::Json,
file: Some(output_file.clone()),
..Default::default()
},
enrichment: sbom_tools::config::EnrichmentConfig::default(),
webhook_url: None,
exit_on_change: true,
max_snapshots: 10,
quiet: true,
dry_run: false,
cra_standards_enabled: false,
cra_standards_interval: Duration::from_secs(86_400),
cra_standards_timeout: Duration::from_secs(10),
};
let config_clone = config.clone();
let fixture_clone = fixture_path.clone();
let handle = std::thread::spawn(move || sbom_tools::watch::run_watch_loop(&config_clone));
std::thread::sleep(Duration::from_millis(100));
let demo_new =
std::fs::read_to_string("tests/fixtures/demo-new.cdx.json").expect("read new fixture");
std::fs::write(&fixture_clone, &demo_new).expect("modify fixture");
let result = handle.join().expect("thread join");
assert!(result.is_ok());
if output_file.exists() {
let output = std::fs::read_to_string(&output_file).expect("read output");
for line in output.lines() {
let parsed: serde_json::Value =
serde_json::from_str(line).expect("each line should be valid JSON");
assert!(
parsed.get("type").is_some(),
"each event should have a 'type' field"
);
}
}
}