use std::collections::HashMap;
use keyhog_core::{
dedup_matches, sha256_hash, DedupScope, MatchLocation, RawMatch, RedactedFinding, Severity,
VerificationResult, VerifiedFinding,
};
const AKIA_PLAINTEXT: &str = "AKIAIOSFODNN7EXAMPLE";
fn make_raw(confidence: Option<f64>) -> RawMatch {
RawMatch {
detector_id: "aws-access-key-id".into(),
detector_name: "AWS Access Key ID".into(),
service: "aws".into(),
severity: Severity::Critical,
credential: AKIA_PLAINTEXT.into(),
credential_hash: sha256_hash(AKIA_PLAINTEXT),
companions: HashMap::new(),
location: MatchLocation {
source: "filesystem".into(),
file_path: Some("config/prod.env".into()),
line: Some(42),
offset: 100,
commit: None,
author: None,
date: None,
},
entropy: Some(4.5),
confidence,
evidence: keyhog_core::EvidenceVerdict::review_unattributed(),
}
}
fn make_verified(confidence: Option<f64>) -> VerifiedFinding {
VerifiedFinding {
detector_id: "aws-access-key-id".into(),
detector_name: "AWS Access Key ID".into(),
service: "aws".into(),
severity: Severity::Critical,
credential_redacted: "AK****LE".into(),
credential_hash: sha256_hash(AKIA_PLAINTEXT),
companions_redacted: std::collections::HashMap::new(),
location: MatchLocation {
source: "filesystem".into(),
file_path: None,
line: None,
offset: 0,
commit: None,
author: None,
date: None,
},
verification: VerificationResult::Unverifiable,
metadata: HashMap::new(),
additional_locations: Vec::new(),
entropy: None,
evidence_score: confidence,
evidence: keyhog_core::EvidenceVerdict::review_unattributed(),
}
}
#[test]
fn measured_entropy_survives_redaction_dedup_and_verified_serialization() {
let raw = make_raw(Some(0.91));
let redacted = raw.to_redacted();
assert_eq!(redacted.entropy, Some(4.5));
let group = dedup_matches(vec![raw], &DedupScope::Credential)
.into_iter()
.next()
.expect("one raw match produces one group");
assert_eq!(group.entropy, Some(4.5));
let finding = VerifiedFinding::from_deduped(
group,
Severity::Critical,
VerificationResult::Skipped,
HashMap::new(),
);
assert_eq!(finding.entropy, Some(4.5));
let value = serde_json::to_value(&finding).expect("verified finding serializes");
assert_eq!(value["entropy"].as_f64(), Some(4.5));
}
#[test]
fn redacted_match_evidence_score_some_serializes_exact_number() {
let redacted = make_raw(Some(0.4)).to_redacted();
let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
assert_eq!(value["evidence_score"].as_f64(), Some(0.4));
assert!(value["evidence_score"].is_number());
assert!(!value["evidence_score"].is_string());
}
#[test]
fn redacted_match_evidence_score_none_is_omitted_from_object() {
let redacted = make_raw(None).to_redacted();
let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
let obj = value.as_object().expect("object");
assert!(!obj.contains_key("evidence_score"));
assert_eq!(obj.get("entropy").and_then(|v| v.as_f64()), Some(4.5));
}
#[test]
fn redacted_match_evidence_score_zero_boundary_is_present_not_omitted() {
let redacted = make_raw(Some(0.0)).to_redacted();
let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
let obj = value.as_object().expect("object");
assert!(obj.contains_key("evidence_score"));
assert_eq!(value["evidence_score"].as_f64(), Some(0.0));
}
#[test]
fn redacted_match_evidence_score_one_boundary_serializes_one() {
let redacted = make_raw(Some(1.0)).to_redacted();
let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
assert_eq!(value["evidence_score"].as_f64(), Some(1.0));
}
#[test]
fn redacted_match_evidence_score_roundtrips_exact() {
let redacted = make_raw(Some(0.9)).to_redacted();
let json = serde_json::to_string(&redacted).expect("serialize");
let back: RedactedFinding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.evidence_score, Some(0.9));
assert_eq!(back.detector_id, redacted.detector_id);
assert_eq!(back.credential_hash, redacted.credential_hash);
}
#[test]
fn redacted_match_evidence_score_preserves_full_f64_precision() {
let precise = 0.123_456_789_012_345_67_f64;
let redacted = make_raw(Some(precise)).to_redacted();
let json = serde_json::to_string(&redacted).expect("serialize");
let back: RedactedFinding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.evidence_score, Some(precise));
let got = back.evidence_score.expect("some");
assert!((got - precise).abs() < 1e-15, "got {got}");
}
#[test]
fn raw_match_confidence_deserializes_from_explicit_field() {
let json = r#"{
"detector_id":"aws-access-key-id",
"detector_name":"AWS Access Key ID",
"service":"aws",
"severity":"critical",
"credential":"AKIAIOSFODNN7EXAMPLE",
"credential_hash":"1a5d44a2dca19669d72edf4c4f1c27c4c1ca4b4408fbb17f6ce4ad452d78ddb3",
"companions":{},
"location":{"source":"filesystem","file_path":null,"line":null,"offset":0,"commit":null,"author":null,"date":null},
"confidence":0.72,
"evidence":{"tier":"review","reason_code":"unattributed","provenance":{"schema_version":1,"detector_digest":null,"pattern_index":null,"candidate_channel":"unattributed","source_role":"unknown","context_class":"unattributed"}}
}"#;
let raw: RawMatch = serde_json::from_str(json).expect("deserialize hand JSON");
assert_eq!(raw.confidence, Some(0.72));
assert_eq!(raw.entropy, None);
}
#[test]
fn verified_finding_evidence_score_some_included_and_field_count_is_14() {
let finding = make_verified(Some(0.9));
let value = serde_json::to_value(&finding).expect("serialize VerifiedFinding");
let obj = value.as_object().expect("object");
assert_eq!(obj.len(), 14);
assert!(obj.contains_key("evidence_score"));
assert_eq!(value["evidence_score"].as_f64(), Some(0.9));
}
#[test]
fn verified_finding_evidence_score_none_omitted_and_field_count_is_13() {
let finding = make_verified(None);
let value = serde_json::to_value(&finding).expect("serialize VerifiedFinding");
let obj = value.as_object().expect("object");
assert_eq!(obj.len(), 13);
assert!(!obj.contains_key("evidence_score"));
assert!(obj.contains_key("remediation"));
}
#[test]
fn verified_finding_evidence_score_zero_is_present_field_count_14() {
let finding = make_verified(Some(0.0));
let value = serde_json::to_value(&finding).expect("serialize VerifiedFinding");
let obj = value.as_object().expect("object");
assert_eq!(obj.len(), 14);
assert_eq!(value["evidence_score"].as_f64(), Some(0.0));
}
#[test]
fn redacted_finding_evidence_score_propagates_from_raw_match() {
let raw = make_raw(Some(0.55));
let redacted: RedactedFinding = raw.to_redacted();
assert_eq!(redacted.evidence_score, Some(0.55));
let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
assert_eq!(value["evidence_score"].as_f64(), Some(0.55));
}
#[test]
fn redacted_finding_evidence_score_none_is_omitted() {
let raw = make_raw(None);
let redacted = raw.to_redacted();
assert_eq!(redacted.evidence_score, None);
let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
let obj = value.as_object().expect("object");
assert!(!obj.contains_key("evidence_score"));
}
#[test]
fn redacted_finding_evidence_score_roundtrips_exact() {
let raw = make_raw(Some(0.33));
let redacted = raw.to_redacted();
let json = serde_json::to_string(&redacted).expect("serialize");
let back: RedactedFinding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.evidence_score, Some(0.33));
}
#[test]
fn raw_match_ord_sorts_higher_confidence_first() {
let low = make_raw(Some(0.30));
let high = make_raw(Some(0.90));
let mut v = vec![low, high];
v.sort();
assert_eq!(v[0].confidence, Some(0.90));
assert_eq!(v[1].confidence, Some(0.30));
let a = make_raw(Some(0.90));
let b = make_raw(Some(0.30));
assert_eq!(a.cmp(&b), std::cmp::Ordering::Less); }
#[test]
fn raw_match_ord_none_confidence_sorts_below_some() {
let none_conf = make_raw(None);
let some_conf = make_raw(Some(0.5));
let mut v = vec![none_conf, some_conf];
v.sort();
assert_eq!(v[0].confidence, Some(0.5));
assert_eq!(v[1].confidence, None);
}
#[test]
fn raw_match_ord_some_zero_and_none_have_distinct_identity_order() {
let some_zero = make_raw(Some(0.0));
let none_conf = make_raw(None);
assert_ne!(some_zero.cmp(&none_conf), std::cmp::Ordering::Equal);
assert_ne!(some_zero, none_conf);
}