use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const GH_PAT: &str = "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";
const GH_ID: &str = "github-classic-pat";
const GH_SERVICE: &str = "github";
const GH_SEVERITY: &str = "critical";
const GH_HASH: &str = "7b85310a29300230c865bc48ca1836f15b81bd50ac85e8c0785e8145e98ff175";
const ROME_VALUE: &str = "aZ4rT9kL2mQ7xB5nV8wY3cF6sH1dJ0gPeUoIsRbX";
const ROME_ID: &str = "rome2rio-api-key";
const ROME_SEVERITY: &str = "low";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn crit_only_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("crit.txt");
std::fs::write(&path, format!("{GH_PAT}\n")).expect("write crit fixture");
(dir, path)
}
fn low_only_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("low.txt");
std::fs::write(&path, format!("rome2rio={ROME_VALUE}\n")).expect("write low fixture");
(dir, path)
}
fn combined_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("both.txt");
std::fs::write(&path, format!("{GH_PAT}\nrome2rio={ROME_VALUE}\n"))
.expect("write combined fixture");
(dir, path)
}
fn scan(path: &Path, extra: &[&str]) -> (Option<i32>, String, String) {
let mut cmd = Command::new(binary());
cmd.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--no-suppress-test-fixtures",
]);
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.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn findings(stdout: &str) -> Vec<serde_json::Value> {
let v: serde_json::Value =
serde_json::from_str(stdout).unwrap_or_else(|e| panic!("stdout not JSON ({e}):\n{stdout}"));
v.as_array()
.expect("json report is a top-level array")
.clone()
}
fn count_detector(stdout: &str, id: &str) -> usize {
findings(stdout)
.iter()
.filter(|f| f.get("detector_id").and_then(|d| d.as_str()) == Some(id))
.count()
}
fn severities(stdout: &str) -> Vec<String> {
findings(stdout)
.iter()
.filter_map(|f| f.get("severity").and_then(|s| s.as_str()).map(String::from))
.collect()
}
#[test]
fn scan_help_documents_severity_flag_and_values() {
let out = Command::new(binary())
.args(["scan", "--help"])
.output()
.expect("spawn keyhog scan --help");
assert_eq!(out.status.code(), Some(0), "--help exits 0");
let help = String::from_utf8_lossy(&out.stdout);
assert!(
help.contains("--severity"),
"help must document the long flag `--severity`; got:\n{help}"
);
assert!(
help.contains("Min severity to report"),
"help must carry the severity floor description; got:\n{help}"
);
for level in ["info", "client-safe", "low", "medium", "high", "critical"] {
assert!(
help.contains(level),
"help must list severity level `{level}`; got:\n{help}"
);
}
}
#[test]
fn crit_only_fixture_reports_one_critical_github_finding() {
let (_d, path) = crit_only_fixture();
let (code, out, err) = scan(&path, &["--format", "json"]);
assert_eq!(code, Some(1), "a finding must exit 1; stderr={err}");
assert_eq!(
count_detector(&out, GH_ID),
1,
"exactly one github-classic-pat; got {out}"
);
let sevs = severities(&out);
assert_eq!(
sevs,
vec![GH_SEVERITY.to_string()],
"single critical severity"
);
}
#[test]
fn low_only_fixture_reports_one_low_rome2rio_finding() {
let (_d, path) = low_only_fixture();
let (code, out, err) = scan(&path, &["--format", "json"]);
assert_eq!(code, Some(1), "a finding must exit 1; stderr={err}");
assert_eq!(
count_detector(&out, ROME_ID),
1,
"exactly one rome2rio-api-key; got {out}"
);
let sevs = severities(&out);
assert_eq!(sevs, vec![ROME_SEVERITY.to_string()], "single low severity");
}
#[test]
fn combined_no_filter_reports_both_critical_and_low() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json"]);
assert_eq!(code, Some(1), "findings present must exit 1; stderr={err}");
assert_eq!(
count_detector(&out, GH_ID),
1,
"one github critical; got {out}"
);
assert_eq!(
count_detector(&out, ROME_ID),
1,
"one rome2rio low; got {out}"
);
let mut sevs = severities(&out);
sevs.sort();
assert_eq!(
sevs,
vec!["critical".to_string(), "low".to_string()],
"exactly the critical+low pair, nothing else; got {out}"
);
}
#[test]
fn severity_critical_keeps_only_the_critical_finding() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "critical"]);
assert_eq!(code, Some(1), "critical survivor exits 1; stderr={err}");
assert_eq!(count_detector(&out, GH_ID), 1, "github kept; got {out}");
assert_eq!(
count_detector(&out, ROME_ID),
0,
"rome2rio dropped; got {out}"
);
assert_eq!(
severities(&out),
vec!["critical".to_string()],
"only critical survives; got {out}"
);
}
#[test]
fn severity_high_drops_low_keeps_critical() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "high"]);
assert_eq!(code, Some(1), "critical survives high floor; stderr={err}");
assert_eq!(
count_detector(&out, ROME_ID),
0,
"low dropped by high floor; got {out}"
);
assert_eq!(count_detector(&out, GH_ID), 1, "critical kept; got {out}");
for sev in severities(&out) {
assert!(
sev == "high" || sev == "critical",
"high floor must leave only high/critical; saw `{sev}` in {out}"
);
}
}
#[test]
fn severity_medium_drops_low_keeps_critical() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "medium"]);
assert_eq!(
code,
Some(1),
"critical survives medium floor; stderr={err}"
);
assert_eq!(
count_detector(&out, ROME_ID),
0,
"low dropped by medium floor; got {out}"
);
assert_eq!(count_detector(&out, GH_ID), 1, "critical kept; got {out}");
assert!(
!severities(&out).iter().any(|s| s == "low"),
"no `low` finding may survive a medium floor; got {out}"
);
}
#[test]
fn severity_low_boundary_keeps_both_findings() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "low"]);
assert_eq!(code, Some(1), "findings present exits 1; stderr={err}");
assert_eq!(
count_detector(&out, GH_ID),
1,
"critical kept at low floor; got {out}"
);
assert_eq!(
count_detector(&out, ROME_ID),
1,
"low finding kept at exactly-equal low floor; got {out}"
);
}
#[test]
fn severity_info_floor_keeps_both_findings() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "info"]);
assert_eq!(code, Some(1), "findings present exits 1; stderr={err}");
assert_eq!(count_detector(&out, GH_ID), 1, "critical kept; got {out}");
assert_eq!(
count_detector(&out, ROME_ID),
1,
"low kept under info floor; got {out}"
);
}
#[test]
fn low_finding_filtered_by_critical_floor_exits_zero_empty() {
let (_d, path) = low_only_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "critical"]);
assert_eq!(
code,
Some(0),
"empty post-filter report exits 0; stderr={err}"
);
assert_eq!(
out.trim_end(),
"[]",
"report must be exactly `[]`; got {out:?}"
);
assert_eq!(findings(&out).len(), 0, "zero findings survive the floor");
}
#[test]
fn low_finding_filtered_by_high_floor_exits_zero_empty() {
let (_d, path) = low_only_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "--severity", "high"]);
assert_eq!(
code,
Some(0),
"empty post-filter report exits 0; stderr={err}"
);
assert_eq!(
findings(&out).len(),
0,
"zero findings survive the high floor; got {out}"
);
}
#[test]
fn short_flag_s_matches_long_severity() {
let (_d, path) = combined_fixture();
let (code, out, err) = scan(&path, &["--format", "json", "-s", "critical"]);
assert_eq!(
code,
Some(1),
"short -s critical survivor exits 1; stderr={err}"
);
assert_eq!(
count_detector(&out, GH_ID),
1,
"github kept via -s; got {out}"
);
assert_eq!(
count_detector(&out, ROME_ID),
0,
"rome2rio dropped via -s; got {out}"
);
}
#[test]
fn invalid_severity_value_is_user_error_exit_two() {
let (_d, path) = combined_fixture();
let (code, _out, err) = scan(&path, &["--format", "json", "--severity", "extreme"]);
assert_eq!(
code,
Some(2),
"unknown --severity value is a user error; stderr={err}"
);
assert!(
err.contains("severity"),
"clap error must name the `severity` flag; got {err}"
);
}
#[test]
fn filter_preserves_surviving_finding_identity() {
let (_d, path) = combined_fixture();
let (code, out, _e) = scan(&path, &["--format", "json", "--severity", "critical"]);
assert_eq!(code, Some(1), "survivor exits 1");
let f = findings(&out);
assert_eq!(
f.len(),
1,
"exactly one finding survives the critical floor; got {out}"
);
let obj = &f[0];
assert_eq!(
obj.get("detector_id").and_then(|d| d.as_str()),
Some(GH_ID),
"survivor is the github detector"
);
assert_eq!(
obj.get("service").and_then(|s| s.as_str()),
Some(GH_SERVICE),
"survivor service unchanged by filtering"
);
assert_eq!(
obj.get("credential_hash").and_then(|h| h.as_str()),
Some(GH_HASH),
"survivor credential_hash is the exact sha256 of the planted PAT"
);
}