use keyhog::testing::{CliTestApi as _, API};
use keyhog_core::VerificationResult as V;
use keyhog_core::{MatchLocation, Severity, VerifiedFinding};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use tempfile::TempDir;
const EXIT_SUCCESS: u8 = 0;
const EXIT_LIVE: u8 = 10;
fn finding(verification: V) -> VerifiedFinding {
VerifiedFinding {
detector_id: Arc::from("github-classic-pat"),
detector_name: Arc::from("GitHub Classic PAT"),
service: Arc::from("github"),
severity: Severity::Critical,
credential_redacted: Cow::Borrowed("ghp_...DSiF"),
credential_hash: [0u8; 32].into(),
companions_redacted: std::collections::HashMap::new(),
location: MatchLocation {
source: Arc::from("filesystem"),
file_path: Some(Arc::from("leak.env")),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
verification,
metadata: std::collections::HashMap::new(),
additional_locations: Vec::new(),
entropy: None,
confidence: Some(0.9),
}
}
#[test]
fn empty_findings_yield_success_code() {
assert_eq!(API.scan_exit_code(&[]), EXIT_SUCCESS);
}
#[test]
fn single_live_finding_yields_ten() {
let findings = [finding(V::Live)];
assert_eq!(API.scan_exit_code(&findings), EXIT_LIVE);
}
#[test]
fn skipped_finding_yields_success_not_live() {
let findings = [finding(V::Skipped)];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn dead_finding_yields_success() {
let findings = [finding(V::Dead)];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn revoked_finding_yields_success() {
let findings = [finding(V::Revoked)];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn rate_limited_finding_yields_success() {
let findings = [finding(V::RateLimited)];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn error_finding_yields_success() {
let findings = [finding(V::Error("connection reset".to_string()))];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn unverifiable_finding_yields_success() {
let findings = [finding(V::Unverifiable)];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn one_live_among_non_live_still_yields_ten() {
let findings = [finding(V::Dead), finding(V::Live), finding(V::Skipped)];
assert_eq!(API.scan_exit_code(&findings), EXIT_LIVE);
}
#[test]
fn all_non_live_mix_yields_success() {
let findings = [
finding(V::Dead),
finding(V::Skipped),
finding(V::Revoked),
finding(V::RateLimited),
finding(V::Unverifiable),
finding(V::Error("timeout".to_string())),
];
assert_eq!(API.scan_exit_code(&findings), EXIT_SUCCESS);
}
#[test]
fn multiple_live_findings_yield_ten() {
let findings = [finding(V::Live), finding(V::Live)];
assert_eq!(API.scan_exit_code(&findings), EXIT_LIVE);
}
#[test]
fn live_as_last_element_is_detected() {
let findings = [
finding(V::Skipped),
finding(V::Dead),
finding(V::Revoked),
finding(V::Live),
];
assert_eq!(API.scan_exit_code(&findings), EXIT_LIVE);
}
#[test]
fn exit_code_constants_have_documented_numbers() {
assert_eq!(keyhog::exit_codes::EXIT_SUCCESS, 0);
assert_eq!(keyhog::exit_codes::EXIT_FINDINGS, 1);
assert_eq!(keyhog::exit_codes::EXIT_USER_ERROR, 2);
assert_eq!(keyhog::exit_codes::EXIT_LIVE_CREDENTIALS, 10);
assert_eq!(EXIT_LIVE, keyhog::exit_codes::EXIT_LIVE_CREDENTIALS);
assert_eq!(EXIT_SUCCESS, keyhog::exit_codes::EXIT_SUCCESS);
}
#[test]
fn exit_code_definitions_document_both_meanings_of_ten() {
let live = keyhog::exit_codes::DEFINITIONS
.iter()
.find(|d| d.code == 10)
.expect("exit code 10 must be documented in DEFINITIONS");
assert_eq!(live.label, "Live credentials found or update available");
assert_eq!(
live.help,
"Live credentials found under scan --verify, or update available under update --check"
);
assert!(
live.scan_reachable,
"exit 10 is reachable from a scan run (with --verify)"
);
assert!(
keyhog::exit_codes::help().contains(
"Live credentials found under scan --verify, or update available under update --check"
),
"help text must document the live-credentials exit code"
);
}
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const PLANTED: &str = concat!("ghp_", "1234567890123456789012345678902PDSiF");
fn scan(path: &Path, extra: &[&str]) -> (Option<i32>, String) {
let mut cmd = Command::new(binary());
cmd.args(["scan", "--daemon=off"]);
if !extra.contains(&"--backend") {
cmd.args(["--backend", "simd"]);
}
cmd.args(extra);
cmd.arg(path);
cmd.env_remove("KEYHOG_BACKEND");
let out = cmd.output().expect("spawn keyhog scan");
(
out.status.code(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn e2e_clean_scan_exits_zero() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.rs");
std::fs::write(&path, "fn main() { println!(\"no secrets\"); }\n").expect("write clean");
let (code, stderr) = scan(&path, &["--format", "json"]);
assert_eq!(code, Some(0), "clean tree must exit 0; stderr={stderr}");
}
#[test]
fn e2e_planted_unverified_finding_exits_one_never_ten() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("leak.env");
std::fs::write(&path, format!("GITHUB_TOKEN={PLANTED}\n")).expect("write planted");
let (code, stderr) = scan(&path, &["--format", "json"]);
assert_eq!(
code,
Some(1),
"unverified planted secret is findings→exit 1, never live→10; stderr={stderr}"
);
assert_ne!(
code,
Some(10),
"an unverified finding must never surface as a live credential"
);
}
#[test]
fn e2e_missing_path_exits_two() {
let missing = PathBuf::from("/keyhog-exit-contract-no-such-path-a1b2c3");
let (code, stderr) = scan(&missing, &["--format", "json"]);
assert_eq!(
code,
Some(2),
"a named path that does not exist is a user error → exit 2; stderr={stderr}"
);
}
#[test]
fn e2e_invalid_backend_exits_two() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.txt");
std::fs::write(&path, "hello world\n").expect("write clean");
let (code, stderr) = scan(&path, &["--backend", "quantum"]);
assert_eq!(
code,
Some(2),
"an unknown --backend value is a user error → exit 2; stderr={stderr}"
);
}
#[test]
fn e2e_doctor_exits_zero_on_healthy_host() {
let output = Command::new(binary())
.arg("doctor")
.output()
.expect("run keyhog doctor");
assert_eq!(
output.status.code(),
Some(0),
"doctor must exit 0 on a healthy host; stdout:\n{}",
String::from_utf8_lossy(&output.stdout)
);
}