use std::borrow::Cow;
use std::collections::HashMap;
use keyhog_core::{
write_report, CredentialHash, MatchLocation, ReportFormat, Severity, VerificationResult,
VerifiedFinding,
};
const ESC: char = '\u{1b}';
fn finding(
detector_id: &'static str,
detector_name: &'static str,
service: &'static str,
severity: Severity,
redacted: &'static str,
verification: VerificationResult,
line: Option<usize>,
confidence: Option<f64>,
) -> VerifiedFinding {
VerifiedFinding {
detector_id: detector_id.into(),
detector_name: detector_name.into(),
service: service.into(),
severity,
credential_redacted: Cow::Borrowed(redacted),
credential_hash: CredentialHash::from_bytes([0x11; 32]),
companions_redacted: std::collections::HashMap::new(),
location: MatchLocation {
source: "filesystem".into(),
file_path: Some("config/app.env".into()),
line,
offset: 0,
commit: None,
author: None,
date: None,
},
verification,
metadata: HashMap::new(),
additional_locations: vec![],
entropy: None,
confidence,
}
}
fn aws_high() -> VerifiedFinding {
finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
Some(7),
Some(0.9),
)
}
fn render_text_full(
findings: &[VerifiedFinding],
color: bool,
example_suppressions: usize,
dogfood_active: bool,
) -> String {
let mut buf = Vec::new();
write_report(
&mut buf,
ReportFormat::Text {
color,
example_suppressions,
dogfood_active,
},
findings,
)
.expect("write_report(Text) must succeed");
String::from_utf8(buf).expect("text output must be valid UTF-8")
}
fn render_text(findings: &[VerifiedFinding]) -> String {
render_text_full(findings, false, 0, false)
}
#[test]
fn text_header_has_name_and_right_aligned_severity() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("─── AWS Access Key"),
"header must render the detector NAME after the rule, got:\n{text}"
);
assert!(
text.contains(" HIGH"),
"severity label must be right-aligned to width 11 (7 spaces + HIGH), got:\n{text}"
);
}
#[test]
fn text_does_not_leak_internal_detector_id() {
let f = finding(
"zzz-internal-detector-id-9x7",
"Display Name Only",
"no-such-service-xyz",
Severity::Medium,
"tok_****",
VerificationResult::Unverifiable,
Some(3),
Some(0.5),
);
let text = render_text(&[f]);
assert!(
text.contains("─── Display Name Only"),
"header must show the display NAME, got:\n{text}"
);
assert!(
!text.contains("zzz-internal-detector-id-9x7"),
"internal detector_id must NOT appear in human text, got:\n{text}"
);
}
#[test]
fn text_secret_line_exact_spacing() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("Secret: AKIA****"),
"redacted secret must render as 'Secret: AKIA****' (5 spaces), got:\n{text}"
);
}
#[test]
fn text_location_file_and_line_exact() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("Location: config/app.env:7"),
"location must render as 'Location: config/app.env:7', got:\n{text}"
);
}
#[test]
fn text_location_path_only_when_line_unknown() {
let f = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
None,
Some(0.9),
);
let text = render_text(&[f]);
assert!(
text.contains("Location: config/app.env"),
"path-only location must still render the path, got:\n{text}"
);
assert!(
!text.contains("config/app.env:"),
"a missing line must NOT produce a trailing ':', got:\n{text}"
);
}
#[test]
fn text_confidence_bar_and_percent_for_high_confidence() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("■■■■■□"),
"confidence 0.9 must fill 5/6 bar cells, got:\n{text}"
);
assert!(
text.contains("90%"),
"confidence 0.9 must render '90%', got:\n{text}"
);
}
#[test]
fn text_confidence_over_one_clamps_to_full_bar_and_100_percent() {
let f = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
Some(7),
Some(1.5), );
let text = render_text(&[f]);
assert!(
text.contains("■■■■■■"),
"over-range confidence must fill all 6 cells, got:\n{text}"
);
assert!(
text.contains("100%"),
"over-range confidence must clamp to '100%', got:\n{text}"
);
assert!(
!text.contains("150%") && !text.contains("101%"),
"no percentage above 100 may render, got:\n{text}"
);
}
#[test]
fn text_confidence_nan_renders_zero_percent_empty_bar() {
let f = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
Some(7),
Some(f64::NAN),
);
let text = render_text(&[f]);
assert!(
text.contains("□□□□□□"),
"NaN confidence must render a fully empty 6-cell bar, got:\n{text}"
);
assert!(
text.contains("0%"),
"NaN confidence must render '0%', got:\n{text}"
);
assert!(
!text.to_lowercase().contains("nan"),
"NaN must never leak into the rendered percent, got:\n{text}"
);
}
#[test]
fn text_confidence_absent_renders_empty_bar_zero_percent() {
let f = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
Some(7),
None,
);
let text = render_text(&[f]);
assert!(
text.contains("□□□□□□"),
"absent confidence must render a fully-empty bar, got:\n{text}"
);
assert!(
text.contains("0%"),
"absent confidence must render '0%', got:\n{text}"
);
}
#[test]
fn text_live_finding_shows_live_suffix() {
let f = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::Critical,
"AKIA****",
VerificationResult::Live,
Some(7),
Some(0.9),
);
let text = render_text(&[f]);
assert!(
text.contains("(LIVE)"),
"a live finding must render the '(LIVE)' verification suffix, got:\n{text}"
);
}
#[test]
fn text_summary_single_is_singular() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("1 secret found"),
"one finding must read '1 secret found', got:\n{text}"
);
assert!(
!text.contains("1 secrets found"),
"singular count must not be pluralized, got:\n{text}"
);
}
#[test]
fn text_summary_counts_three_exactly() {
let text = render_text(&[aws_high(), aws_high(), aws_high()]);
assert!(
text.contains("3 secrets found"),
"three findings must read '3 secrets found', got:\n{text}"
);
assert!(
!text.contains("2 secrets found") && !text.contains("4 secrets found"),
"the count must be exact, got:\n{text}"
);
}
#[test]
fn text_summary_live_and_unverified_split() {
let live = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Live,
Some(7),
Some(0.9),
);
let text = render_text(&[live, aws_high()]);
assert!(text.contains("2 secrets found"), "got:\n{text}");
assert!(
text.contains("1 live"),
"one live must show '1 live', got:\n{text}"
);
assert!(
text.contains("1 unverified"),
"the Unverifiable finding must show '1 unverified', got:\n{text}"
);
assert!(
!text.contains("dead"),
"no verified-inactive finding, so no 'dead' tally, got:\n{text}"
);
}
#[test]
fn text_summary_dead_and_revoked_both_count_dead() {
let dead = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Dead,
Some(7),
Some(0.9),
);
let revoked = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Revoked,
Some(8),
Some(0.9),
);
let text = render_text(&[dead, revoked]);
assert!(text.contains("2 secrets found"), "got:\n{text}");
assert!(
text.contains("2 dead"),
"Dead∪Revoked must roll into '2 dead', got:\n{text}"
);
assert!(
!text.contains("unverified"),
"verified-inactive findings must not appear as unverified, got:\n{text}"
);
}
#[test]
fn text_summary_banner_and_next_steps() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("━━━ Results"),
"results banner missing, got:\n{text}"
);
assert!(
text.contains("1. Revoke active secrets in the provider's dashboard."),
"step 1 text missing, got:\n{text}"
);
assert!(
text.contains("2. Remove credentials from codebase and git history."),
"step 2 text missing, got:\n{text}"
);
assert!(
text.contains("3. Use a secure secret manager or environment variables."),
"step 3 text missing, got:\n{text}"
);
}
#[test]
fn text_empty_scan_honest_no_secrets_line() {
let text = render_text(&[]);
assert!(
text.contains("No secrets detected in the scanned files."),
"clean scan must render the exact honest line, got:\n{text}"
);
assert!(
!text.to_lowercase().contains("clean"),
"must never claim the code is 'clean', got:\n{text}"
);
assert!(
!text.contains("secret found") && !text.contains("secrets found"),
"clean scan must print no found-count, got:\n{text}"
);
}
#[test]
fn text_empty_scan_example_suppressions_plural_hint() {
let text = render_text_full(&[], false, 2, false);
assert!(
text.contains(
"No real secrets, but 2 example/test keys suppressed. Pass --dogfood to see them."
),
"plural suppression phrasing (with hint) mismatch, got:\n{text}"
);
assert!(
!text.contains("No secrets detected in the scanned files."),
"suppression line must replace the plain honest line, got:\n{text}"
);
}
#[test]
fn text_empty_scan_single_suppression_dogfood_phrasing() {
let text = render_text_full(&[], false, 1, true);
assert!(
text.contains("No real secrets, but 1 example/test key suppressed (see --dogfood output above for the full list)."),
"singular + dogfood-active phrasing mismatch, got:\n{text}"
);
assert!(
!text.contains("Pass --dogfood to see them"),
"the 'Pass --dogfood' hint must be dropped when dogfood is already active, got:\n{text}"
);
}
#[test]
fn text_no_color_emits_no_ansi_escapes() {
let text = render_text(&[aws_high()]);
assert!(
!text.contains(ESC),
"color:false must emit no ESC/ANSI bytes anywhere, got:\n{text:?}"
);
let empty = render_text(&[]);
assert!(
!empty.contains(ESC),
"color:false clean-scan must emit no ESC/ANSI bytes, got:\n{empty:?}"
);
}
#[test]
fn text_color_wraps_severity_label_in_exact_sgr() {
let high = render_text_full(&[aws_high()], true, 0, false);
assert!(
high.contains("\u{1b}[31m HIGH\u{1b}[0m"),
"colored HIGH label must be exact SGR-31, got:\n{high:?}"
);
let critical_finding = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::Critical,
"AKIA****",
VerificationResult::Unverifiable,
Some(7),
Some(0.9),
);
let crit = render_text_full(&[critical_finding], true, 0, false);
assert!(
crit.contains("\u{1b}[1;31m CRITICAL\u{1b}[0m"),
"colored CRITICAL label must be exact SGR-1;31, got:\n{crit:?}"
);
}
#[test]
fn text_redacted_ansi_injection_is_neutralized() {
let malicious = finding(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"\u{1b}[31mPWNED\u{1b}[0m",
VerificationResult::Unverifiable,
Some(7),
Some(0.9),
);
let text = render_text(&[malicious]);
assert!(
!text.contains(ESC),
"an injected ESC in the redacted value must be stripped, got:\n{text:?}"
);
assert!(
text.contains('\u{FFFD}'),
"stripped control bytes must be visible as U+FFFD, got:\n{text:?}"
);
assert!(
!text.contains("\u{1b}[31mPWNED"),
"the injected SGR escape sequence must not survive intact, got:\n{text:?}"
);
assert!(
text.contains("PWNED"),
"non-control characters of the redacted value must survive, got:\n{text}"
);
}