use keyhog::testing::{CliTestApi as _, API};
use keyhog_core::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
use std::collections::HashMap;
use std::sync::Arc;
fn make_finding(
detector_id: &str,
credential_hash: &str,
file_path: Option<&str>,
) -> VerifiedFinding {
VerifiedFinding {
detector_id: Arc::from(detector_id),
detector_name: Arc::from("Test Detector"),
service: Arc::from("test"),
severity: Severity::High,
credential_redacted: "***".into(),
credential_hash: test_hash(credential_hash).into(),
companions_redacted: std::collections::HashMap::new(),
location: MatchLocation {
source: Arc::from("filesystem"),
file_path: file_path.map(Arc::from),
line: Some(42),
offset: 0,
commit: None,
author: None,
date: None,
},
verification: VerificationResult::Skipped,
metadata: HashMap::new(),
additional_locations: Vec::new(),
entropy: None,
confidence: None,
}
}
fn test_hash(seed: &str) -> [u8; 32] {
let mut out = [0u8; 32];
for (idx, byte) in seed.as_bytes().iter().copied().take(32).enumerate() {
out[idx] = byte;
}
out
}
fn baseline_hash(seed: &str) -> String {
format!("sha256:{}", keyhog_core::hex_encode(&test_hash(seed)))
}
#[test]
fn baseline_creation_produces_expected_entries() {
let findings = vec![
make_finding("github-pat", "abc123", Some("src/config.py")),
make_finding("aws-key", "def456", Some("src/aws.py")),
];
let baseline = API.baseline_from_findings(&findings);
assert_eq!(baseline.version, 1);
assert_eq!(baseline.entries.len(), 2);
assert_eq!(baseline.entries[0].detector_id, "aws-key");
assert_eq!(baseline.entries[0].credential_hash, baseline_hash("def456"));
assert_eq!(
baseline.entries[0].file_path,
Some("src/aws.py".to_string())
);
assert_eq!(baseline.entries[0].line, Some(42));
}
#[test]
fn baseline_creation_dedupes_duplicate_credentials() {
let findings = vec![
make_finding("github-pat", "abc123", Some("src/config.py")),
make_finding("github-pat", "abc123", Some("src/other.py")),
];
let baseline = API.baseline_from_findings(&findings);
assert_eq!(baseline.entries.len(), 1);
assert_eq!(baseline.entries[0].detector_id, "github-pat");
}
#[test]
fn baseline_suppresses_known_findings() {
let findings = vec![
make_finding("github-pat", "abc123", Some("src/config.py")),
make_finding("aws-key", "def456", Some("src/aws.py")),
];
let baseline = API.baseline_from_findings(&findings);
let suppressed = API.baseline_filter_new(&baseline, &findings);
assert!(suppressed.is_empty());
}
#[test]
fn baseline_does_not_suppress_new_findings() {
let baseline =
API.baseline_from_findings(&[make_finding("github-pat", "abc123", Some("src/config.py"))]);
let new_findings = vec![
make_finding("github-pat", "abc123", Some("src/config.py")),
make_finding("github-pat", "newhash", Some("src/new.py")),
];
let filtered = API.baseline_filter_new(&baseline, &new_findings);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].credential_hash, test_hash("newhash").into());
}
#[test]
fn baseline_update_adds_new_findings() {
let mut baseline =
API.baseline_from_findings(&[make_finding("github-pat", "abc123", Some("src/config.py"))]);
let new_findings = vec![
make_finding("github-pat", "abc123", Some("src/config.py")),
make_finding("aws-key", "def456", Some("src/aws.py")),
];
API.baseline_merge(&mut baseline, &new_findings);
assert_eq!(baseline.entries.len(), 2);
let ids: Vec<_> = baseline
.entries
.iter()
.map(|e| e.detector_id.as_str())
.collect();
assert!(ids.contains(&"github-pat"));
assert!(ids.contains(&"aws-key"));
}
#[test]
fn baseline_save_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("baseline.json");
let findings = vec![make_finding("github-pat", "abc123", Some("src/config.py"))];
let baseline = API.baseline_from_findings(&findings);
API.baseline_save(&baseline, &path).unwrap();
let loaded = API.baseline_load(&path).unwrap();
assert_eq!(loaded, baseline);
}
#[test]
fn baseline_status_is_not_serialized_but_legacy_status_loads() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("baseline.json");
let baseline =
API.baseline_from_findings(&[make_finding("github-pat", "abc123", Some("src/config.py"))]);
API.baseline_save(&baseline, &path).unwrap();
let serialized = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert!(
parsed["entries"][0].get("status").is_none(),
"baseline status is not a real suppression state and must not be serialized: {serialized}"
);
let legacy = format!(
r#"{{
"version": 1,
"created": "legacy",
"entries": [{{
"detector_id": "github-pat",
"credential_hash": "{}",
"file_path": "src/config.py",
"line": 42,
"status": "rejected"
}}]
}}"#,
baseline_hash("abc123")
);
std::fs::write(&path, legacy).unwrap();
let loaded = API.baseline_load(&path).unwrap();
assert!(API.baseline_contains(
&loaded,
&make_finding("github-pat", "abc123", Some("src/moved.py"))
));
}
#[test]
fn baseline_matching_ignores_file_path_and_line() {
let findings = vec![make_finding("github-pat", "abc123", Some("src/config.py"))];
let baseline = API.baseline_from_findings(&findings);
let moved_finding = make_finding("github-pat", "abc123", Some("src/moved.py"));
assert!(API.baseline_contains(&baseline, &moved_finding));
}
use std::io::Write;
#[test]
fn findings_report_array_is_recognized() {
assert!(API
.baseline_looks_like_findings_report(r#"[{"detector_id":"github-classic-pat","line":1}]"#));
}
#[test]
fn findings_report_object_without_baseline_keys_is_recognized() {
assert!(API.baseline_looks_like_findings_report(r#"{"results":[],"summary":{}}"#));
}
#[test]
fn real_baseline_is_not_flagged_as_findings_report() {
assert!(
!API.baseline_looks_like_findings_report(r#"{"version":1,"created":"now","entries":[]}"#)
);
}
#[test]
fn load_of_scan_report_gives_actionable_error_not_serde_noise() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, r#"[{{"detector_id":"github-classic-pat","line":1}}]"#).unwrap();
let err = API
.baseline_load(tmp.path())
.expect_err("a findings array is not a baseline");
let msg = format!("{err:#}");
assert!(
msg.contains("--create-baseline"),
"error must point at `--create-baseline`, got: {msg}"
);
assert!(
!msg.contains("expected u32"),
"raw serde noise must be suppressed, got: {msg}"
);
}
#[test]
fn load_of_valid_baseline_roundtrips() {
let b = API.baseline_empty();
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "{}", serde_json::to_string(&b).unwrap()).unwrap();
let loaded = API.baseline_load(tmp.path()).expect("valid baseline loads");
assert_eq!(loaded.version, API.baseline_version());
}
#[test]
fn unknown_baseline_fields_fail_closed_but_legacy_status_is_explicit() {
let mut root = tempfile::NamedTempFile::new().unwrap();
write!(
root,
r#"{{"version":1,"created":"now","entries":[],"reviewd":true}}"#
)
.unwrap();
let root_error = API
.baseline_load(root.path())
.expect_err("unknown root fields must not silently change baseline policy");
assert!(
format!("{root_error:#}").contains("reviewd"),
"root typo should be named in the parse error: {root_error:#}"
);
let mut entry = tempfile::NamedTempFile::new().unwrap();
write!(
entry,
r#"{{"version":1,"created":"now","entries":[{{"detector_id":"aws-key","credential_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","file_path":"x","line":1,"reviewd":true}}]}}"#
)
.unwrap();
let entry_error = API
.baseline_load(entry.path())
.expect_err("unknown entry fields must not silently change suppression policy");
assert!(
format!("{entry_error:#}").contains("reviewd"),
"entry typo should be named in the parse error: {entry_error:#}"
);
let mut legacy = tempfile::NamedTempFile::new().unwrap();
write!(
legacy,
r#"{{"version":1,"created":"legacy","entries":[{{"detector_id":"aws-key","credential_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","file_path":"x","line":1,"status":"rejected"}}]}}"#
)
.unwrap();
API.baseline_load(legacy.path())
.expect("the documented legacy status alias remains readable");
}