| 1 | use super::*; |
| 2 | use clap::Parser as _; |
| 3 | |
| 4 | #[test] |
| 5 | fn default_probe_request_is_fully_offline() { |
| 6 | let request = DoctorProbeRequest::default(); |
| 7 | assert!(!request.should_check_updates()); |
| 8 | assert!(!request.should_probe_api(false)); |
| 9 | assert!(!request.should_probe_api(true)); |
| 10 | assert!(!request.should_probe_mcp()); |
| 11 | } |
| 12 | |
| 13 | #[test] |
| 14 | fn update_renderer_omits_untrusted_release_tags_and_errors() { |
| 15 | let release_sentinel = "v9.9.9?token=doctor-update-sentinel"; |
| 16 | let error_sentinel = "https://user:doctor-update-error-sentinel@example.test/path"; |
| 17 | |
| 18 | let metadata = doctor_update_report("0.9.3", Ok::<String, ()>(release_sentinel.to_string())); |
| 19 | let transport = doctor_update_report("0.9.3", Err(error_sentinel.to_string())); |
| 20 | let rendered = [ |
| 21 | doctor_update_report_lines(&metadata).join("\n"), |
| 22 | doctor_update_report_lines(&transport).join("\n"), |
| 23 | ] |
| 24 | .join("\n"); |
| 25 | |
| 26 | assert_eq!(metadata, DoctorUpdateReport::ReleaseMetadataInvalid); |
| 27 | assert_eq!(transport, DoctorUpdateReport::ReleaseCheckFailed); |
| 28 | assert!(!rendered.contains(release_sentinel)); |
| 29 | assert!(!rendered.contains(error_sentinel)); |
| 30 | assert!(rendered.contains("details omitted")); |
| 31 | } |
| 32 | |
| 33 | #[test] |
| 34 | fn update_renderer_canonicalizes_safe_release_tags() { |
| 35 | let report = doctor_update_report("0.9.3", Ok::<String, ()>(" v0.9.4 ".to_string())); |
| 36 | assert_eq!( |
| 37 | doctor_update_report_lines(&report), |
| 38 | vec![ |
| 39 | "latest: v0.9.4".to_string(), |
| 40 | "Update available. Run `codewhale update` to install.".to_string(), |
| 41 | ] |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | #[test] |
| 46 | fn live_probe_flags_open_only_their_owned_boundary() { |
| 47 | let update = DoctorProbeRequest { |
| 48 | check_updates: true, |
| 49 | ..DoctorProbeRequest::default() |
| 50 | }; |
| 51 | assert!(update.should_check_updates()); |
| 52 | assert!(!update.should_probe_api(false)); |
| 53 | assert!(!update.should_probe_api(true)); |
| 54 | |
| 55 | let hosted = DoctorProbeRequest { |
| 56 | probe_api: true, |
| 57 | ..DoctorProbeRequest::default() |
| 58 | }; |
| 59 | assert!(hosted.should_probe_api(false)); |
| 60 | assert!(!hosted.should_probe_api(true)); |
| 61 | |
| 62 | let local = DoctorProbeRequest { |
| 63 | probe_local: true, |
| 64 | ..DoctorProbeRequest::default() |
| 65 | }; |
| 66 | assert!(!local.should_probe_api(false)); |
| 67 | assert!(local.should_probe_api(true)); |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn cli_defaults_doctor_offline_and_keeps_json_incompatible_with_live_flags() { |
| 72 | let cli = |
| 73 | crate::Cli::try_parse_from(["codewhale-tui", "doctor"]).expect("parse default doctor"); |
| 74 | let Some(crate::Commands::Doctor(args)) = cli.command else { |
| 75 | panic!("expected doctor command"); |
| 76 | }; |
| 77 | assert!(!args.check_updates); |
| 78 | assert!(!args.probe_api); |
| 79 | assert!(!args.probe_local); |
| 80 | assert!(!args.probe_mcp); |
| 81 | |
| 82 | for flag in [ |
| 83 | "--check-updates", |
| 84 | "--probe-api", |
| 85 | "--probe-local", |
| 86 | "--probe-mcp", |
| 87 | ] { |
| 88 | assert!( |
| 89 | crate::Cli::try_parse_from(["codewhale-tui", "doctor", "--json", flag]).is_err(), |
| 90 | "--json unexpectedly accepted live flag {flag}" |
| 91 | ); |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | #[test] |
| 96 | fn explicit_codewhale_home_owns_every_default_user_path() { |
| 97 | let _lock = crate::test_support::lock_test_env(); |
| 98 | let temp = tempfile::tempdir().expect("temp home"); |
| 99 | let home = temp.path().join("isolated-codewhale-home"); |
| 100 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 101 | let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"); |
| 102 | let _legacy_config = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"); |
| 103 | let _automations = crate::test_support::EnvVarGuard::remove("CODEWHALE_AUTOMATIONS_DIR"); |
| 104 | let _legacy_automations = crate::test_support::EnvVarGuard::remove("DEEPSEEK_AUTOMATIONS_DIR"); |
| 105 | let _tasks = crate::test_support::EnvVarGuard::remove("CODEWHALE_TASKS_DIR"); |
| 106 | let _legacy_tasks = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TASKS_DIR"); |
| 107 | let _runtime = crate::test_support::EnvVarGuard::remove("CODEWHALE_RUNTIME_DIR"); |
| 108 | let _legacy_runtime = crate::test_support::EnvVarGuard::remove("DEEPSEEK_RUNTIME_DIR"); |
| 109 | |
| 110 | let report = DoctorPathReport::resolve(None).expect("resolve doctor paths"); |
| 111 | let task_manager_root = crate::task_manager::default_tasks_dir(); |
| 112 | let runtime_config = crate::runtime_threads::RuntimeThreadManagerConfig::from_task_data_dir( |
| 113 | task_manager_root.clone(), |
| 114 | ); |
| 115 | let (secrets, legacy_secrets) = |
| 116 | codewhale_secrets::FileKeyringStore::default_paths_read_only().expect("secret paths"); |
| 117 | |
| 118 | assert_eq!(report.home, home); |
| 119 | assert_eq!(report.config, home.join("config.toml")); |
| 120 | assert_eq!(report.settings, home.join("settings.toml")); |
| 121 | assert_eq!(report.sessions, home.join("sessions")); |
| 122 | assert_eq!(report.logs, home.join("logs")); |
| 123 | assert_eq!(report.automations, home.join("automations")); |
| 124 | assert_eq!(report.task_manager_root, task_manager_root); |
| 125 | assert_eq!(report.task_manager_tasks, task_manager_root.join("tasks")); |
| 126 | assert_eq!( |
| 127 | report.task_manager_artifacts, |
| 128 | task_manager_root.join("artifacts") |
| 129 | ); |
| 130 | assert_eq!(report.runtime_store, runtime_config.data_dir); |
| 131 | assert_eq!( |
| 132 | report.runtime_events, |
| 133 | runtime_config.data_dir.join("events") |
| 134 | ); |
| 135 | assert_eq!( |
| 136 | report.personal_fleet_definitions, |
| 137 | crate::fleet::exact::personal_fleet_definitions_dir().expect("personal fleets") |
| 138 | ); |
| 139 | assert_eq!( |
| 140 | report.personal_fleet_agents, |
| 141 | crate::fleet::profile::personal_agent_profile_dir().expect("personal agents") |
| 142 | ); |
| 143 | assert_eq!(report.secrets, secrets); |
| 144 | assert_eq!(legacy_secrets, None); |
| 145 | assert_eq!(report.entries().len(), 14); |
| 146 | let json = serde_json::to_value(&report).expect("serialize path snapshot"); |
| 147 | for (label, path) in report.entries() { |
| 148 | assert_eq!( |
| 149 | json[label].as_str(), |
| 150 | Some(path.to_string_lossy().as_ref()), |
| 151 | "human and JSON path snapshots diverged for {label}" |
| 152 | ); |
| 153 | } |
| 154 | assert!( |
| 155 | !home.exists(), |
| 156 | "path reporting must not create the configured home" |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | #[test] |
| 161 | fn explicit_relative_config_matches_the_canonical_loader_path() { |
| 162 | let relative = Path::new("fixtures/relative-doctor-config.toml"); |
| 163 | let expected = codewhale_config::resolve_config_path(Some(relative.to_path_buf())) |
| 164 | .expect("canonical config path"); |
| 165 | |
| 166 | let report = DoctorPathReport::resolve(Some(relative)).expect("resolve doctor paths"); |
| 167 | |
| 168 | assert_eq!(report.config, expected); |
| 169 | assert!(report.config.is_absolute()); |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn path_report_json_contains_no_secret_file_contents() { |
| 174 | let _lock = crate::test_support::lock_test_env(); |
| 175 | let temp = tempfile::tempdir().expect("temp home"); |
| 176 | let home = temp.path().join("isolated-codewhale-home"); |
| 177 | let secret_path = home.join("secrets").join("secrets.json"); |
| 178 | std::fs::create_dir_all(secret_path.parent().unwrap()).expect("secret dir fixture"); |
| 179 | let sentinel = "doctor-path-report-secret-sentinel"; |
| 180 | std::fs::write(&secret_path, sentinel).expect("secret fixture"); |
| 181 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 182 | |
| 183 | let report = DoctorPathReport::resolve(None).expect("resolve doctor paths"); |
| 184 | let json = serde_json::to_string(&report).expect("serialize path report"); |
| 185 | |
| 186 | assert!(!json.contains(sentinel)); |
| 187 | assert_eq!(std::fs::read_to_string(secret_path).unwrap(), sentinel); |
| 188 | } |
| 189 | |
| 190 | #[test] |
| 191 | fn human_and_json_backend_reports_never_include_secret_file_contents() { |
| 192 | let _lock = crate::test_support::lock_test_env(); |
| 193 | let temp = tempfile::tempdir().expect("temp home"); |
| 194 | let home = temp.path().join("isolated-codewhale-home"); |
| 195 | let secret_path = home.join("secrets").join("secrets.json"); |
| 196 | std::fs::create_dir_all(secret_path.parent().unwrap()).expect("secret dir fixture"); |
| 197 | let sentinel = "doctor-render-secret-sentinel"; |
| 198 | std::fs::write(&secret_path, format!("not-json:{sentinel}")).expect("secret fixture"); |
| 199 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 200 | let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file"); |
| 201 | |
| 202 | let diagnostic = codewhale_secrets::diagnose_secret_backend(); |
| 203 | let human = secret_backend_human_lines(&diagnostic).join("\n"); |
| 204 | let json = serde_json::to_string(&diagnostic).expect("serialize backend diagnostic"); |
| 205 | |
| 206 | assert!(!human.contains(sentinel)); |
| 207 | assert!(!json.contains(sentinel)); |
| 208 | assert_eq!( |
| 209 | std::fs::read_to_string(secret_path).unwrap(), |
| 210 | format!("not-json:{sentinel}") |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn structural_url_authority_omits_every_secret_capable_component() { |
| 216 | let sentinels = [ |
| 217 | "URL-USER-SENTINEL", |
| 218 | "URL-PASSWORD-SENTINEL", |
| 219 | "URL-PATH-SENTINEL", |
| 220 | "URL-QUERY-KEY-SENTINEL", |
| 221 | "URL-QUERY-VALUE-SENTINEL", |
| 222 | "URL-FRAGMENT-SENTINEL", |
| 223 | ]; |
| 224 | let raw = format!( |
| 225 | "https://{}:{}@example.invalid:8443/{}/child?{}={}#{}", |
| 226 | sentinels[0], sentinels[1], sentinels[2], sentinels[3], sentinels[4], sentinels[5] |
| 227 | ); |
| 228 | |
| 229 | let authority = structural_url_authority(&raw); |
| 230 | |
| 231 | assert_eq!(authority, "https://example.invalid:8443"); |
| 232 | for sentinel in sentinels { |
| 233 | assert!(!authority.contains(sentinel)); |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | #[test] |
| 238 | fn credential_shaped_config_values_are_flagged_by_key_name_only() { |
| 239 | // Fixture tokens stay low-entropy on purpose: realistic random strings |
| 240 | // trip secret scanners (GitGuardian flagged the originals as live credentials). |
| 241 | let raw = r#" |
| 242 | # comment with sk-not-a-real-line |
| 243 | model = "deepseek-v4-flash" |
| 244 | base_url = "https://api.moonshot.ai/kimi-code/v1" |
| 245 | chatgpt_access_token = "eyJ0000000000000000000000000" |
| 246 | moonshot_api_key = "[redacted]" |
| 247 | provider_api_key = "sk-test0000000000000000" |
| 248 | workspace_token_note = "short" |
| 249 | random_id = "0123456789abcdef0123456789abcdef" |
| 250 | "#; |
| 251 | let flagged = super::config_credential_shaped_keys(raw); |
| 252 | assert_eq!(flagged, vec!["chatgpt_access_token", "provider_api_key"]); |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn credential_scan_ignores_urls_models_and_redacted_entries() { |
| 257 | let raw = r#" |
| 258 | model = "kimi-k3-instruct-preview-2026" |
| 259 | endpoint = "https://example.com/v1?key=nope" |
| 260 | api_key = "[redacted]" |
| 261 | "#; |
| 262 | assert!(super::config_credential_shaped_keys(raw).is_empty()); |
| 263 | } |
| 264 |