//! Verification tests mapped to //! `docs/security/diagnostic-redaction-audit-report.md` §4 //! (REDACT-TC-001..010). //! //! Each test name carries its REDACT-TC- identifier for traceability. use diagnostics_redaction_spike::{ bundle::DiagnosticBundle, Redactor, REDACTION_MARKER, }; use indoc::indoc; // Unique markers so a leak is unambiguous in test output. const SERVER_PASSWORD: &str = "REDACT_POC_PW_a9c41f"; const IDENTITY_SECRET: &str = "MG0DAgeAAgEgAiAIXJBlj1hQbaH0Eq0DuLlCmH8bl_veTAO2_k9EQjEYSgIgNnImcmKo7ls5mExb6skfK2Twu54aeDr0OP1ITsC50CIA"; const AUTH_TOKEN: &str = "tok_REDACT_POC_TOKEN_8d11"; fn fresh_redactor() -> Redactor { Redactor::with_default_policy() } // ---------- REDACT-TC-001 ---------- #[test] fn redact_tc_001_server_password_in_connection_data() { let r = fresh_redactor(); let input = format!("connect host=cn.teamspeak.app password={SERVER_PASSWORD} nickname=Chanora"); let out = r.redact_text(&input); assert!(!out.contains(SERVER_PASSWORD), "password leaked: {out}"); assert!(out.contains(REDACTION_MARKER), "no marker in {out}"); // The non-secret context must be preserved. assert!(out.contains("host=cn.teamspeak.app")); assert!(out.contains("nickname=Chanora")); } // ---------- REDACT-TC-002 ---------- #[test] fn redact_tc_002_identity_secret_in_storage_error() { let r = fresh_redactor(); // Register the identity as a known literal — strongest defense. r.known_secrets().register(IDENTITY_SECRET); let input = format!( "ERROR: failed to load identity (raw={IDENTITY_SECRET}); falling back to default" ); let out = r.redact_text(&input); assert!(!out.contains(IDENTITY_SECRET), "identity leaked: {out}"); assert!(out.contains(REDACTION_MARKER)); } // ---------- REDACT-TC-003 ---------- #[test] fn redact_tc_003_server_url_with_password_field() { let r = fresh_redactor(); let url = format!("ts3server://cn.teamspeak.app?password={SERVER_PASSWORD}&nickname=Chanora"); let out = r.redact_text(&url); assert!(!out.contains(SERVER_PASSWORD)); // Only the password value should be redacted; host + nickname keep flowing. assert!(out.contains("ts3server://cn.teamspeak.app")); assert!(out.contains("nickname=Chanora")); assert!(out.contains("password=[REDACTED]")); } // ---------- REDACT-TC-004 ---------- #[test] fn redact_tc_004_chat_text_excluded_by_default() { let r = fresh_redactor(); let bundle = DiagnosticBundle { chat_history: vec!["hello!".into(), "private message".into()], ..Default::default() }; let red = r.redact_bundle(&bundle); assert!(red.chat_history.is_empty(), "chat should be excluded by default"); } // ---------- REDACT-TC-005 ---------- #[test] fn redact_tc_005_channel_name_with_unicode_excluded_by_default() { let r = fresh_redactor(); let bundle = DiagnosticBundle { channel_tree: vec!["樱花庄".into(), "Default Channel".into()], ..Default::default() }; let red = r.redact_bundle(&bundle); // Default policy: channel tree excluded (audit-report §5 "TBD / Redact/minimize"). assert!(red.channel_tree.is_empty()); } // ---------- REDACT-TC-006 ---------- #[test] fn redact_tc_006_nickname_with_unicode_preserved_in_safe_field() { // Nickname is conveyed via a non-sensitive extras key; the redactor // does NOT mangle Unicode in safe fields (preserves UTF-8 by // ADR-008, see audit-report row "Multilingual safe diagnostic text"). let r = fresh_redactor(); let mut extras = std::collections::BTreeMap::new(); extras.insert("local_nickname".into(), "クマー".into()); let bundle = DiagnosticBundle { extras, ..Default::default() }; let red = r.redact_bundle(&bundle); assert_eq!(red.extras.get("local_nickname").map(|s| s.as_str()), Some("クマー")); } // ---------- REDACT-TC-007 ---------- #[test] fn redact_tc_007_local_file_paths_user_segment_minimized() { let r = fresh_redactor(); let lines = [ "/home/milkice/chanora/app.log", "C:\\Users\\Alice\\AppData\\Roaming\\Chanora\\log.txt", "/Users/bob/Library/Application Support/Chanora/x.db", ]; for l in lines { let out = r.redact_text(l); assert!(!out.contains("milkice"), "leaked milkice: {out}"); assert!(!out.contains("Alice"), "leaked Alice: {out}"); assert!(!out.contains("bob"), "leaked bob: {out}"); assert!(out.contains(REDACTION_MARKER), "no marker in {out}"); } } // ---------- REDACT-TC-008 ---------- #[test] fn redact_tc_008_diagnostic_bundle_with_mixed_sensitive_fields() { let r = fresh_redactor(); r.known_secrets().register(IDENTITY_SECRET); let mut extras = std::collections::BTreeMap::new(); extras.insert("server_password".into(), SERVER_PASSWORD.into()); extras.insert("auth_token".into(), AUTH_TOKEN.into()); extras.insert("app_locale".into(), "en-US".into()); extras.insert("user_home".into(), "/home/milkice".into()); let bundle = DiagnosticBundle { app_version: "0.0.0-poc".into(), build_number: "1".into(), platform_info: "Linux x86_64 path=/home/milkice/.local".into(), connection_state: format!("disconnected; last_error=password={SERVER_PASSWORD}"), server_address: Some(format!( "ts3server://cn.teamspeak.app?password={SERVER_PASSWORD}" )), channel_tree: vec!["whatever".into()], chat_history: vec!["should not appear".into()], log_lines: vec![ format!("INFO loaded identity={IDENTITY_SECRET}"), format!("WARN auth failed Authorization: Bearer {AUTH_TOKEN}"), ], audio_device_names: vec!["Built-in Microphone".into()], extras, }; let red = r.redact_bundle(&bundle); // Whole-bundle leak check: no plaintext anywhere. let blob = serde_json::to_string(&red).unwrap(); assert!(!blob.contains(SERVER_PASSWORD), "server password leaked: {blob}"); assert!(!blob.contains(IDENTITY_SECRET), "identity leaked: {blob}"); assert!(!blob.contains(AUTH_TOKEN), "auth token leaked: {blob}"); assert!(!blob.contains("milkice"), "username leaked: {blob}"); assert!(!blob.contains("should not appear"), "chat leaked: {blob}"); // Positive checks: structural fields keep flowing. assert_eq!(red.app_version, "0.0.0-poc"); assert_eq!(red.build_number, "1"); assert!(red.audio_device_names.contains(&"Built-in Microphone".to_string())); assert_eq!(red.extras.get("app_locale").map(|s| s.as_str()), Some("en-US")); assert_eq!(red.extras.get("server_password").map(|s| s.as_str()), Some(REDACTION_MARKER)); assert_eq!(red.extras.get("auth_token").map(|s| s.as_str()), Some(REDACTION_MARKER)); } // ---------- REDACT-TC-009 ---------- #[test] fn redact_tc_009_long_hostile_protocol_string_truncated() { let r = fresh_redactor(); // A long benign string that does NOT match any redaction rule // (no base64-style run, no path, no key=value secret) — so the // length cap is the only thing that can act on it. let huge: String = "hello world! ".repeat(400); let out = r.redact_text(&huge); assert!(out.len() < huge.len(), "expected truncation; got len={} input_len={}", out.len(), huge.len()); assert!(out.ends_with("…[truncated]"), "missing truncation marker; tail={:?}", &out[out.len().saturating_sub(40)..]); } // ---------- REDACT-TC-010 ---------- #[test] fn redact_tc_010_multilingual_safe_text_preserved() { let r = fresh_redactor(); let lines = indoc! {" Welcome to Vigorous Pro! 欢迎来到 Vigorous Pro 日本語チャンネル 한국어 채널 Café résumé naïve "}; let out = r.redact_text(lines); for l in [ "Vigorous Pro", "欢迎来到", "日本語チャンネル", "한국어 채널", "Café résumé naïve", ] { assert!(out.contains(l), "missing line {l:?} in {out}"); } // No redaction markers should appear for benign multilingual text. assert!(!out.contains(REDACTION_MARKER)); } // ---------- Additional sanity ---------- #[test] fn known_secret_is_scrubbed_even_when_no_regex_matches() { let r = fresh_redactor(); let weird = "BANANA_PHONE_42"; r.known_secrets().register(weird); let out = r.redact_text(&format!("note: secret value is {weird} here")); assert!(!out.contains(weird)); assert!(out.contains(REDACTION_MARKER)); } #[test] fn empty_registered_secret_is_ignored() { let r = fresh_redactor(); r.known_secrets().register(""); let out = r.redact_text("nothing to redact"); assert_eq!(out, "nothing to redact"); }