use std::collections::HashSet;
use std::time::Duration;
use keyhog_core::VerificationResult;
use keyhog_verifier::oob::InteractshError;
use keyhog_verifier::testing::{
invalid_url_error, redact_interactsh_error, BODY_NOT_UTF8_ERROR, BODY_READ_FAILED_ERROR,
CONNECTION_FAILED_ERROR, DNS_NO_ADDRESSES_ERROR, HTTPS_ONLY_ERROR, PRIVATE_URL_ERROR,
REDIRECT_LIMIT_ERROR, REQUEST_FAILED_ERROR, RESPONSE_TOO_LARGE_ERROR, TIMEOUT_ERROR,
};
#[test]
fn live_verdict_serializes_to_snake_case_live() {
let json = serde_json::to_string(&VerificationResult::Live).expect("serialize Live");
assert_eq!(json, "\"live\"");
}
#[test]
fn dead_verdict_serializes_to_snake_case_dead() {
let json = serde_json::to_string(&VerificationResult::Dead).expect("serialize Dead");
assert_eq!(json, "\"dead\"");
}
#[test]
fn rate_limited_verdict_serializes_snake_case() {
let json =
serde_json::to_string(&VerificationResult::RateLimited).expect("serialize RateLimited");
assert_eq!(json, "\"rate_limited\"");
}
#[test]
fn timeout_maps_to_error_variant_with_exact_payload_under_error_tag() {
let verdict = VerificationResult::Error(TIMEOUT_ERROR.to_string());
let json = serde_json::to_string(&verdict).expect("serialize Error");
let value: serde_json::Value = serde_json::from_str(&json).expect("reparse Error json");
let obj = value
.as_object()
.expect("Error serializes to a JSON object");
assert_eq!(obj.len(), 1, "Error object has exactly one key");
let payload = obj
.get("error")
.and_then(|v| v.as_str())
.expect("object is keyed by `error` with a string payload");
assert_eq!(payload, TIMEOUT_ERROR);
}
#[test]
fn all_seven_verdict_tags_are_distinct_and_named() {
let variants = [
VerificationResult::Live,
VerificationResult::Revoked,
VerificationResult::Dead,
VerificationResult::RateLimited,
VerificationResult::Error("boom".to_string()),
VerificationResult::Unverifiable,
VerificationResult::Skipped,
];
let tags: Vec<String> = variants
.iter()
.map(|v| serde_json::to_string(v).expect("serialize verdict"))
.collect();
assert_eq!(tags[0], "\"live\"");
assert_eq!(tags[1], "\"revoked\"");
assert_eq!(tags[2], "\"dead\"");
assert_eq!(tags[3], "\"rate_limited\"");
assert_eq!(tags[4], "{\"error\":\"boom\"}");
assert_eq!(tags[5], "\"unverifiable\"");
assert_eq!(tags[6], "\"skipped\"");
let unique: HashSet<&String> = tags.iter().collect();
assert_eq!(unique.len(), 7, "all seven verdict tags must be distinct");
}
#[test]
fn error_verdict_roundtrips_and_preserves_exact_reason() {
let original = VerificationResult::Error(CONNECTION_FAILED_ERROR.to_string());
let json = serde_json::to_string(&original).expect("serialize");
let restored: VerificationResult = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored, original);
match restored {
VerificationResult::Error(reason) => assert_eq!(reason, CONNECTION_FAILED_ERROR),
other => panic!("expected Error variant, got {other:?}"),
}
}
#[test]
fn snake_case_tags_deserialize_to_expected_unit_verdicts() {
let live: VerificationResult = serde_json::from_str("\"live\"").expect("live");
let dead: VerificationResult = serde_json::from_str("\"dead\"").expect("dead");
let rl: VerificationResult = serde_json::from_str("\"rate_limited\"").expect("rate_limited");
let skipped: VerificationResult = serde_json::from_str("\"skipped\"").expect("skipped");
assert_eq!(live, VerificationResult::Live);
assert_eq!(dead, VerificationResult::Dead);
assert_eq!(rl, VerificationResult::RateLimited);
assert_eq!(skipped, VerificationResult::Skipped);
let bogus: Result<VerificationResult, _> = serde_json::from_str("\"totally_live\"");
assert!(
bogus.is_err(),
"unknown verdict tag must fail to deserialize"
);
}
#[test]
fn timeout_error_leads_with_legacy_phrase_and_is_actionable() {
assert!(
TIMEOUT_ERROR.starts_with("timeout:"),
"timeout reason must lead with the legacy `timeout:` phrase for substring compat"
);
assert!(TIMEOUT_ERROR.contains("--timeout"));
assert!(TIMEOUT_ERROR.contains("Fix:"));
assert!(!TIMEOUT_ERROR.starts_with("blocked:"));
}
#[test]
fn connection_failed_error_leads_with_legacy_phrase() {
assert!(CONNECTION_FAILED_ERROR.starts_with("connection failed:"));
assert!(CONNECTION_FAILED_ERROR.contains("DNS"));
assert!(CONNECTION_FAILED_ERROR.contains("Fix:"));
assert_ne!(CONNECTION_FAILED_ERROR, TIMEOUT_ERROR);
}
#[test]
fn redirect_and_request_failed_are_distinct_and_prefixed() {
assert!(REDIRECT_LIMIT_ERROR.starts_with("too many redirects:"));
assert!(REQUEST_FAILED_ERROR.starts_with("request failed:"));
assert_ne!(REDIRECT_LIMIT_ERROR, REQUEST_FAILED_ERROR);
assert!(REDIRECT_LIMIT_ERROR.contains("Fix:"));
assert!(REQUEST_FAILED_ERROR.contains("Fix:"));
}
#[test]
fn refusal_family_is_blocked_prefixed_with_no_fix() {
assert_eq!(PRIVATE_URL_ERROR, "blocked: private URL");
assert_eq!(HTTPS_ONLY_ERROR, "blocked: HTTPS only");
assert_eq!(DNS_NO_ADDRESSES_ERROR, "blocked: DNS returned no addresses");
for refusal in [PRIVATE_URL_ERROR, HTTPS_ONLY_ERROR, DNS_NO_ADDRESSES_ERROR] {
assert!(
refusal.starts_with("blocked:"),
"refusal must be blocked-prefixed: {refusal}"
);
assert!(
!refusal.contains("Fix:"),
"refusal must NOT carry a Fix: {refusal}"
);
}
}
#[test]
fn all_probe_error_reasons_are_pairwise_distinct() {
let reasons = [
TIMEOUT_ERROR,
CONNECTION_FAILED_ERROR,
REDIRECT_LIMIT_ERROR,
REQUEST_FAILED_ERROR,
BODY_READ_FAILED_ERROR,
RESPONSE_TOO_LARGE_ERROR,
BODY_NOT_UTF8_ERROR,
PRIVATE_URL_ERROR,
HTTPS_ONLY_ERROR,
DNS_NO_ADDRESSES_ERROR,
];
let unique: HashSet<&&str> = reasons.iter().collect();
assert_eq!(
unique.len(),
reasons.len(),
"all {} probe error reasons must be distinct",
reasons.len()
);
}
#[test]
fn response_body_errors_lead_with_legacy_phrases() {
assert!(BODY_READ_FAILED_ERROR.starts_with("body read failed:"));
assert!(RESPONSE_TOO_LARGE_ERROR.starts_with("response body exceeds 1MB limit:"));
assert!(BODY_NOT_UTF8_ERROR.starts_with("body is not utf-8:"));
assert!(BODY_READ_FAILED_ERROR.contains("Fix:"));
assert!(RESPONSE_TOO_LARGE_ERROR.contains("Fix:"));
assert!(BODY_NOT_UTF8_ERROR.contains("Fix:"));
}
#[test]
fn invalid_url_error_embeds_parse_error_and_is_actionable() {
let parse_detail = "relative URL without a base";
let msg = invalid_url_error(parse_detail);
assert!(
msg.starts_with("invalid URL:"),
"must lead with `invalid URL:`: {msg}"
);
assert!(
msg.contains(parse_detail),
"must embed the underlying parse error: {msg}"
);
assert!(msg.contains("Fix:"), "must be actionable with a Fix: {msg}");
assert!(
!msg.starts_with("blocked:"),
"invalid URL is actionable, not a refusal: {msg}"
);
}
#[test]
fn invalid_url_error_varies_with_the_parse_cause() {
let a = invalid_url_error("empty host");
let b = invalid_url_error("invalid port number");
assert_ne!(a, b);
assert!(a.contains("empty host"));
assert!(b.contains("invalid port number"));
}
#[test]
fn redact_passes_through_non_transport_poll_error_verbatim() {
let err = InteractshError::Poll {
status: 403,
body: "access denied".to_string(),
};
let redacted = redact_interactsh_error(&err);
assert_eq!(redacted, "interactsh poll failed (HTTP 403): access denied");
}
#[test]
fn redact_renders_timeout_variant_with_exact_duration() {
let err = InteractshError::Timeout(Duration::from_secs(7));
let redacted = redact_interactsh_error(&err);
assert_eq!(redacted, "interactsh request timed out after 7s");
}
#[test]
fn redact_keeps_register_and_poll_phases_distinct() {
let register = redact_interactsh_error(&InteractshError::Register {
status: 500,
body: "boom".to_string(),
});
let poll = redact_interactsh_error(&InteractshError::Poll {
status: 500,
body: "boom".to_string(),
});
assert_eq!(register, "interactsh register failed (HTTP 500): boom");
assert_eq!(poll, "interactsh poll failed (HTTP 500): boom");
assert_ne!(register, poll);
}