use crate::e2e::support::{binary, run};
use std::process::Command;
use tempfile::TempDir;
fn out(args: &[&str]) -> (String, String, Option<i32>) {
let o = run(args);
(
String::from_utf8_lossy(&o.stdout).into_owned(),
String::from_utf8_lossy(&o.stderr).into_owned(),
o.status.code(),
)
}
fn out_stdin(args: &[&str], stdin_data: &str) -> (String, String, Option<i32>) {
use std::io::Write;
let mut child = Command::new(binary())
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn keyhog");
child
.stdin
.take()
.unwrap()
.write_all(stdin_data.as_bytes())
.unwrap();
let o = child.wait_with_output().expect("wait keyhog");
(
String::from_utf8_lossy(&o.stdout).into_owned(),
String::from_utf8_lossy(&o.stderr).into_owned(),
o.status.code(),
)
}
fn tmp_with(name: &str, content: &str) -> (TempDir, String) {
let d = TempDir::new().unwrap();
let p = d.path().join(name);
std::fs::write(&p, content).unwrap();
let s = p.to_string_lossy().into_owned();
(d, s)
}
const AWS: &str = "AWS_ACCESS_KEY_ID=AKIAQYLPMN5HFIQR7XYA\n";
const CLEAN: &str = "the quick brown fox jumps over the lazy dog\n";
#[test]
fn version_prints_keyhog_and_exits_zero() {
let (so, _se, code) = out(&["--version"]);
assert_eq!(code, Some(0));
assert!(
so.contains("KeyHog") || so.to_lowercase().contains("keyhog"),
"got: {so}"
);
}
#[test]
fn help_lists_core_subcommands() {
let (so, _se, code) = out(&["--help"]);
assert_eq!(code, Some(0));
for sub in [
"scan",
"detectors",
"explain",
"doctor",
"completion",
"backend",
] {
assert!(so.contains(sub), "help missing subcommand `{sub}`:\n{so}");
}
}
#[test]
fn unknown_subcommand_is_clap_usage_error_exit_2() {
let (_so, se, code) = out(&["definitely-not-a-subcommand"]);
assert_eq!(code, Some(2), "clap usage errors exit 2");
assert!(
se.to_lowercase().contains("error") || se.contains("Usage"),
"got: {se}"
);
}
#[test]
fn unknown_global_flag_is_usage_error_exit_2() {
let (_so, _se, code) = out(&["--no-such-flag"]);
assert_eq!(code, Some(2));
}
#[test]
fn scan_text_secret_exit_1_and_redacts() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, code) = out(&["scan", "--no-daemon", "--format", "text", &p]);
assert_eq!(code, Some(1), "a file with a live key must exit 1");
assert!(
so.contains("AKIA"),
"text report should show a redacted preview:\n{so}"
);
}
#[test]
fn scan_text_clean_exit_0() {
let (_d, p) = tmp_with("c.txt", CLEAN);
let (_so, _se, code) = out(&["scan", "--no-daemon", "--format", "text", &p]);
assert_eq!(code, Some(0));
}
#[test]
fn scan_json_is_valid_array_with_detector_id() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, code) = out(&["scan", "--no-daemon", "--format", "json", &p]);
assert_eq!(code, Some(1));
let v: serde_json::Value =
serde_json::from_str(&so).expect("scan --format json must emit valid JSON");
let arr = v.as_array().expect("findings is a JSON array");
assert!(
arr.iter().any(|f| f["detector_id"] == "aws-access-key"),
"got: {so}"
);
}
#[test]
fn scan_json_clean_is_empty_array_exit_0() {
let (_d, p) = tmp_with("c.txt", CLEAN);
let (so, _se, code) = out(&["scan", "--no-daemon", "--format", "json", &p]);
assert_eq!(code, Some(0));
let v: serde_json::Value = serde_json::from_str(&so).expect("valid JSON");
assert_eq!(
v.as_array().map(|a| a.len()),
Some(0),
"clean scan must be []: {so}"
);
}
#[test]
fn scan_jsonl_each_line_is_a_json_object() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, code) = out(&["scan", "--no-daemon", "--format", "jsonl", &p]);
assert_eq!(code, Some(1));
let mut lines = 0;
for line in so.lines().filter(|l| !l.trim().is_empty()) {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("jsonl line not valid JSON: {e}\n{line}"));
lines += 1;
}
assert!(lines >= 1, "expected at least one jsonl finding");
}
#[test]
fn scan_sarif_has_runs_and_results() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, code) = out(&["scan", "--no-daemon", "--format", "sarif", &p]);
assert_eq!(code, Some(1));
let v: serde_json::Value = serde_json::from_str(&so).expect("sarif is JSON");
assert!(v["runs"].is_array(), "SARIF must have a runs array:\n{so}");
}
#[test]
fn scan_bogus_format_is_usage_error_exit_2() {
let (_d, p) = tmp_with("c.env", AWS);
let (_so, _se, code) = out(&["scan", "--no-daemon", "--format", "yaml-not-real", &p]);
assert_eq!(
code,
Some(2),
"an unsupported --format value is a clap error"
);
}
#[test]
fn scan_stdin_finds_secret() {
let (so, _se, code) = out_stdin(&["scan", "--no-daemon", "--format", "json", "-"], AWS);
assert_eq!(code, Some(1));
let v: serde_json::Value = serde_json::from_str(&so).expect("valid JSON from stdin scan");
assert!(
v.as_array().map(|a| !a.is_empty()).unwrap_or(false),
"stdin scan found nothing: {so}"
);
}
#[test]
fn scan_empty_dir_exit_0() {
let d = TempDir::new().unwrap();
let (_so, _se, code) = out(&["scan", "--no-daemon", d.path().to_str().unwrap()]);
assert_eq!(code, Some(0));
}
#[test]
fn scan_nonexistent_path_errors_nonzero() {
let (_so, _se, code) = out(&["scan", "--no-daemon", "/no/such/path/keyhog-xyz"]);
assert_ne!(
code,
Some(0),
"scanning a missing path must not silently succeed"
);
}
#[test]
fn scan_fast_mode_clean_exit_0() {
let (_d, p) = tmp_with("c.txt", CLEAN);
let (_so, _se, code) = out(&["scan", "--no-daemon", "--fast", "--format", "json", &p]);
assert_eq!(code, Some(0));
}
#[test]
fn scan_fast_mode_still_finds_aws_key() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, code) = out(&["scan", "--no-daemon", "--fast", "--format", "json", &p]);
assert_eq!(code, Some(1));
assert!(
so.contains("aws-access-key"),
"fast mode missed the AWS key: {so}"
);
}
#[test]
fn scan_deep_mode_finds_aws_key() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, code) = out(&["scan", "--no-daemon", "--deep", "--format", "json", &p]);
assert_eq!(code, Some(1));
assert!(so.contains("AKIA"), "deep mode missed the AWS key: {so}");
}
#[test]
fn scan_output_writes_findings_to_file() {
let (_d, p) = tmp_with("c.env", AWS);
let outdir = TempDir::new().unwrap();
let outfile = outdir.path().join("report.json");
let (_so, _se, code) = out(&[
"scan",
"--no-daemon",
"--format",
"json",
"--output",
outfile.to_str().unwrap(),
&p,
]);
assert_eq!(code, Some(1));
let written = std::fs::read_to_string(&outfile).expect("--output file must be written");
let v: serde_json::Value = serde_json::from_str(&written).expect("output file is valid JSON");
assert!(
v.as_array().map(|a| !a.is_empty()).unwrap_or(false),
"output file empty: {written}"
);
}
#[test]
fn scan_no_color_text_has_no_ansi_escape() {
let (_d, p) = tmp_with("c.env", AWS);
let (so, _se, _code) = out(&["scan", "--no-daemon", "--no-color", "--format", "text", &p]);
assert!(
!so.contains('\u{1b}'),
"--no-color output must contain no ANSI escapes"
);
}
#[test]
fn detectors_list_is_nonempty() {
let (so, _se, code) = out(&["detectors"]);
assert_eq!(code, Some(0));
assert!(
so.lines().count() > 50,
"expected a long detector list, got {} lines",
so.lines().count()
);
}
#[test]
fn detectors_includes_aws_access_key() {
let (so, _se, _code) = out(&["detectors"]);
assert!(
so.contains("aws-access-key"),
"detector list should include aws-access-key"
);
}
#[test]
fn explain_known_detector_shows_spec() {
let (so, _se, code) = out(&["explain", "aws-access-key"]);
assert_eq!(code, Some(0));
assert!(
so.to_lowercase().contains("aws"),
"explain output should describe the detector:\n{so}"
);
}
#[test]
fn explain_unknown_detector_errors_nonzero() {
let (_so, _se, code) = out(&["explain", "totally-made-up-detector-xyz"]);
assert_ne!(code, Some(0), "explaining an unknown detector must error");
}
#[test]
fn backend_prints_a_backend_label_exit_0() {
let (so, se, code) = out(&["backend"]);
assert_eq!(code, Some(0));
let combined = format!("{so}{se}").to_lowercase();
assert!(
combined.contains("backend")
|| combined.contains("cpu")
|| combined.contains("gpu")
|| combined.contains("simd"),
"backend output should name the selected path:\n{so}\n{se}"
);
}
#[test]
fn doctor_runs_and_reports_sections() {
let (so, se, _code) = out(&["doctor"]);
let combined = format!("{so}{se}");
assert!(
combined.contains("doctor") || combined.to_lowercase().contains("host"),
"doctor should print a report:\n{combined}"
);
}
#[test]
fn completion_emits_script_for_each_shell() {
for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {
let (so, _se, code) = out(&["completion", shell]);
assert_eq!(code, Some(0), "completion {shell} should exit 0");
assert!(
!so.trim().is_empty(),
"completion {shell} produced no script"
);
}
}
#[test]
fn completion_unknown_shell_is_usage_error() {
let (_so, _se, code) = out(&["completion", "not-a-shell"]);
assert_eq!(code, Some(2), "an invalid shell name is a clap value error");
}
#[test]
fn uninstall_without_yes_is_dry_run() {
let (so, se, code) = out(&["uninstall"]);
let combined = format!("{so}{se}").to_lowercase();
assert!(
combined.contains("dry")
|| combined.contains("--yes")
|| combined.contains("would")
|| code == Some(0),
"uninstall without --yes should be a safe dry run:\n{so}\n{se}"
);
}