use keyhog_core::{VerificationResult, VerifySpec};
use keyhog_verifier::testing::{
TestApi, TestVerificationCache as VerificationCache, VerifierTestApi, VerifierTestCache,
};
use std::collections::HashMap;
use std::time::Duration;
const OOB_COMPANION_URL: &str = <TestApi as VerifierTestApi>::OOB_COMPANION_URL;
const OOB_COMPANION_ID: &str = <TestApi as VerifierTestApi>::OOB_COMPANION_ID;
fn companions(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn spec(service: &str, allowed: &[&str]) -> VerifySpec {
VerifySpec {
service: service.to_string(),
allowed_domains: allowed.iter().map(|s| s.to_string()).collect(),
..VerifySpec::default()
}
}
#[test]
fn interpolate_url_var_expands_to_exact_encoded_url() {
let c = companions(&[]);
assert_eq!(
TestApi.interpolate_url("https://api.svc.example/verify/{{match}}", "tok en:v/1", &c),
"https://api.svc.example/verify/tok%20en%3Av%2F1",
"embedded {{match}} in a URL must percent-encode every structural byte"
);
}
#[test]
fn interpolate_url_repeated_match_encodes_each_occurrence() {
let c = companions(&[]);
assert_eq!(
TestApi.interpolate_url("https://h/{{match}}/x/{{match}}", "a b", &c),
"https://h/a%20b/x/a%20b",
"each {{match}} occurrence is independently encoded in one pass"
);
}
#[test]
fn interpolate_unrecognized_token_is_preserved_verbatim() {
let c = companions(&[]);
assert_eq!(
TestApi.interpolate("a{{totally_undefined}}b", "cred", &c),
"a{{totally_undefined}}b"
);
assert_eq!(
TestApi.interpolate("{{no_such_var}}", "cred", &c),
"{{no_such_var}}"
);
}
#[test]
fn interpolate_unterminated_open_brace_is_preserved_verbatim() {
let c = companions(&[]);
assert_eq!(
TestApi.interpolate_url("https://h/path {{match", "TOK", &c),
"https://h/path {{match"
);
assert_eq!(
TestApi.interpolate_url("{{match}} then {{oops", "TOK", &c),
"TOK then {{oops"
);
}
#[test]
fn interpolate_undefined_companion_renders_empty_in_url() {
let c = companions(&[("present", "x")]);
assert_eq!(
TestApi.interpolate_url("https://h/?k={{companion.absent}}&z=1", "cred", &c),
"https://h/?k=&z=1",
"an undefined companion must render to the empty string, never leak another value"
);
}
#[test]
fn interpolate_http_value_credential_carrying_token_is_inert() {
let c = companions(&[("leak", "OTHER_SECRET")]);
assert_eq!(
TestApi.interpolate_http_value("X-Auth: {{match}}", "{{companion.leak}}", &c),
"X-Auth: {{companion.leak}}",
"a substituted value must never be re-expanded (cross-companion leak guard)"
);
}
#[test]
fn interpolate_interactsh_id_token_substituted_and_dns_sanitized() {
let mut c = companions(&[]);
c.insert(OOB_COMPANION_ID.to_string(), "Corr-ID_9/9".to_string());
assert_eq!(
TestApi.interpolate("id={{interactsh.id}}", "cred", &c),
"id=corr-id99"
);
}
#[test]
fn interpolate_oob_url_without_scheme_is_sanitized_whole() {
let mut c = companions(&[]);
c.insert(OOB_COMPANION_URL.to_string(), "ABC.OOB.Example".to_string());
assert_eq!(
TestApi.interpolate("{{interactsh.url}}", "cred", &c),
"abc.oob.example",
"a URL companion with no scheme is DNS-charset sanitized in full"
);
}
#[test]
fn interpolate_oob_url_non_alphabetic_scheme_is_collapsed() {
let mut c = companions(&[]);
c.insert(
OOB_COMPANION_URL.to_string(),
"ht9tp://Evil.Com".to_string(),
);
assert_eq!(
TestApi.interpolate("{{interactsh.url}}", "cred", &c),
"ht9tpevil.com",
"a fake numeric scheme must not smuggle `://` structural bytes through"
);
}
#[test]
fn check_url_offlist_host_reports_exact_error() {
let s = spec("gitlab", &[]);
let err = TestApi
.check_url_against_spec("https://exfil.attacker.example/steal", &s)
.expect_err("off-allowlist host must be blocked");
assert!(err.starts_with("blocked: host "), "err: {err}");
assert!(err.contains("exfil.attacker.example"), "err: {err}");
assert!(err.contains("not in the allowlist"), "err: {err}");
assert!(err.contains("service 'gitlab'"), "err: {err}");
assert!(
err.contains("gitlab.com"),
"must list the allowed apex: {err}"
);
}
#[test]
fn check_url_allows_builtin_subdomain_exactly_ok() {
let s = spec("gitlab", &[]);
assert_eq!(
TestApi.check_url_against_spec("https://api.gitlab.com/api/v4/user", &s),
Ok(())
);
}
#[test]
fn cache_returns_exact_verdict_on_repeat_reads() {
let cache = VerificationCache::new(Duration::from_secs(60));
cache.put(
"cred-A",
"det-A",
VerificationResult::Revoked,
HashMap::new(),
);
let first = cache.get("cred-A", "det-A").expect("first hit");
let second = cache.get("cred-A", "det-A").expect("second hit");
assert_eq!(first.0, VerificationResult::Revoked);
assert_eq!(second.0, VerificationResult::Revoked);
assert_eq!(cache.len(), 1);
}
#[test]
fn cache_overwrite_same_key_updates_verdict_in_place() {
let cache = VerificationCache::new(Duration::from_secs(60));
cache.put("cred", "det", VerificationResult::Live, HashMap::new());
cache.put("cred", "det", VerificationResult::Dead, HashMap::new());
assert_eq!(
cache.get("cred", "det").map(|(r, _)| r),
Some(VerificationResult::Dead),
"the newest verdict wins for the same (credential, detector) key"
);
assert_eq!(cache.len(), 1, "overwrite must not grow the map");
assert_eq!(
cache.queue_len(),
2,
"overwrite refreshes recency: a new generation marker is enqueued and \
the old marker becomes stale (skipped lazily by eviction, swept by \
reconcile), the MAP must not grow, the queue may hold one stale marker"
);
}
#[test]
fn capacity_eviction_prefers_stale_slot_over_refreshed_entry() {
let cache = VerificationCache::with_max_entries(Duration::from_secs(60), 2);
cache.put("refreshed", "det", VerificationResult::Live, HashMap::new());
cache.put("stale", "det", VerificationResult::Live, HashMap::new());
cache.put("refreshed", "det", VerificationResult::Dead, HashMap::new());
cache.put("newest", "det", VerificationResult::Live, HashMap::new());
assert_eq!(cache.len(), 2, "capacity bound enforced");
assert_eq!(
cache.get("refreshed", "det").map(|(r, _)| r),
Some(VerificationResult::Dead),
"the refreshed entry must SURVIVE capacity eviction (its stale marker \
is skipped; its refreshed verdict is the one retained)"
);
assert!(
cache.get("stale", "det").is_none(),
"the genuinely oldest entry is the one evicted"
);
assert!(
cache.get("newest", "det").is_some(),
"the newest insert is retained"
);
}
#[test]
fn cache_queue_tracks_distinct_inserts() {
let cache = VerificationCache::new(Duration::from_secs(60));
cache.put("c0", "d", VerificationResult::Live, HashMap::new());
cache.put("c1", "d", VerificationResult::Dead, HashMap::new());
cache.put("c2", "d", VerificationResult::RateLimited, HashMap::new());
assert_eq!(cache.len(), 3);
assert_eq!(cache.queue_len(), 3, "each distinct insert enqueues once");
}
#[test]
fn cache_metadata_entry_count_capped_at_sixteen() {
let cache = VerificationCache::new(Duration::from_secs(60));
let mut md = HashMap::new();
for i in 0..20 {
md.insert(format!("k{i}"), format!("v{i}"));
}
cache.put("cred", "det", VerificationResult::Live, md);
let (_, meta) = cache.get("cred", "det").expect("hit");
assert_eq!(
meta.len(),
16,
"metadata must be capped at MAX_METADATA_ENTRIES = 16"
);
}
#[test]
fn cache_metadata_value_truncated_to_256_bytes() {
let cache = VerificationCache::new(Duration::from_secs(60));
let mut md = HashMap::new();
md.insert("k".to_string(), "a".repeat(300));
cache.put("cred", "det", VerificationResult::Live, md);
let (_, meta) = cache.get("cred", "det").expect("hit");
assert_eq!(
meta.get("k").map(String::len),
Some(256),
"an over-long metadata value must be truncated to 256 bytes"
);
assert_eq!(
meta.get("k").map(String::as_str),
Some("a".repeat(256).as_str())
);
}
#[test]
fn cache_evict_expired_reconciles_queue_to_zero() {
let cache = VerificationCache::new(Duration::from_secs(0));
cache.put("a", "d", VerificationResult::Live, HashMap::new());
cache.put("b", "d", VerificationResult::Dead, HashMap::new());
assert_eq!(cache.queue_len(), 2, "both inserts enqueue before eviction");
cache.evict_expired();
assert_eq!(cache.len(), 0, "expired entries dropped from the map");
assert_eq!(
cache.queue_len(),
0,
"the FIFO queue must be reconciled so it never dangles past the map"
);
}