| 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 | assert!(!request.should_probe_search()); |
| 12 | } |
| 13 | |
| 14 | fn ds4_config() -> crate::config::Config { |
| 15 | let mut providers = crate::config::ProvidersConfig::default(); |
| 16 | providers.custom.insert( |
| 17 | "ds4".to_string(), |
| 18 | crate::config::ProviderConfig { |
| 19 | kind: Some("openai-compatible".to_string()), |
| 20 | base_url: Some("http://127.0.0.1:8000/v1".to_string()), |
| 21 | model: Some("deepseek-v4-flash".to_string()), |
| 22 | auth_mode: Some("none".to_string()), |
| 23 | ..Default::default() |
| 24 | }, |
| 25 | ); |
| 26 | crate::config::Config { |
| 27 | provider: Some("ds4".to_string()), |
| 28 | providers: Some(providers), |
| 29 | ..Default::default() |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | #[test] |
| 34 | fn recognizes_only_the_explicit_keyless_local_ds4_route() { |
| 35 | let ds4 = ds4_config(); |
| 36 | assert!(is_keyless_ds4_route(&ds4)); |
| 37 | |
| 38 | let mut hosted = ds4.clone(); |
| 39 | hosted |
| 40 | .providers |
| 41 | .as_mut() |
| 42 | .and_then(|providers| providers.custom.get_mut("ds4")) |
| 43 | .expect("DS4 config") |
| 44 | .base_url = Some("https://ds4.example/v1".to_string()); |
| 45 | assert!(!is_keyless_ds4_route(&hosted)); |
| 46 | } |
| 47 | |
| 48 | #[test] |
| 49 | fn ds4_probe_error_reports_status_without_response_body() { |
| 50 | let mut config = ds4_config(); |
| 51 | config |
| 52 | .providers |
| 53 | .as_mut() |
| 54 | .and_then(|providers| providers.custom.get_mut("ds4")) |
| 55 | .expect("DS4 config") |
| 56 | .base_url = Some("http://user:secret@127.0.0.1:8000/v1?token=secret".to_string()); |
| 57 | let message = ds4_probe_error( |
| 58 | &config, |
| 59 | "Failed to list models: HTTP 404: secret backend response", |
| 60 | ); |
| 61 | |
| 62 | assert!(message.contains("HTTP 404")); |
| 63 | assert!(!message.contains("secret backend response")); |
| 64 | assert!(!message.contains("secret")); |
| 65 | } |
| 66 | |
| 67 | #[test] |
| 68 | fn update_renderer_omits_untrusted_release_tags_and_errors() { |
| 69 | let release_sentinel = "v9.9.9?token=doctor-update-sentinel"; |
| 70 | let error_sentinel = "https://user:doctor-update-error-sentinel@example.test/path"; |
| 71 | |
| 72 | let metadata = doctor_update_report("0.9.3", Ok::<String, ()>(release_sentinel.to_string())); |
| 73 | let transport = doctor_update_report("0.9.3", Err(error_sentinel.to_string())); |
| 74 | let rendered = [ |
| 75 | doctor_update_report_lines(&metadata).join("\n"), |
| 76 | doctor_update_report_lines(&transport).join("\n"), |
| 77 | ] |
| 78 | .join("\n"); |
| 79 | |
| 80 | assert_eq!(metadata, DoctorUpdateReport::ReleaseMetadataInvalid); |
| 81 | assert_eq!(transport, DoctorUpdateReport::ReleaseCheckFailed); |
| 82 | assert!(!rendered.contains(release_sentinel)); |
| 83 | assert!(!rendered.contains(error_sentinel)); |
| 84 | assert!(rendered.contains("details omitted")); |
| 85 | } |
| 86 | |
| 87 | #[test] |
| 88 | fn update_renderer_canonicalizes_safe_release_tags() { |
| 89 | let report = doctor_update_report("0.9.3", Ok::<String, ()>(" v0.9.4 ".to_string())); |
| 90 | assert_eq!( |
| 91 | doctor_update_report_lines(&report), |
| 92 | vec![ |
| 93 | "latest: v0.9.4".to_string(), |
| 94 | "Update available. Run `codewhale update` to install.".to_string(), |
| 95 | ] |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | #[test] |
| 100 | fn live_probe_flags_open_only_their_owned_boundary() { |
| 101 | let update = DoctorProbeRequest { |
| 102 | check_updates: true, |
| 103 | ..DoctorProbeRequest::default() |
| 104 | }; |
| 105 | assert!(update.should_check_updates()); |
| 106 | assert!(!update.should_probe_api(false)); |
| 107 | assert!(!update.should_probe_api(true)); |
| 108 | |
| 109 | let hosted = DoctorProbeRequest { |
| 110 | probe_api: true, |
| 111 | ..DoctorProbeRequest::default() |
| 112 | }; |
| 113 | assert!(hosted.should_probe_api(false)); |
| 114 | assert!(!hosted.should_probe_api(true)); |
| 115 | |
| 116 | let local = DoctorProbeRequest { |
| 117 | probe_local: true, |
| 118 | ..DoctorProbeRequest::default() |
| 119 | }; |
| 120 | assert!(!local.should_probe_api(false)); |
| 121 | assert!(local.should_probe_api(true)); |
| 122 | |
| 123 | let search = DoctorProbeRequest { |
| 124 | probe_search: true, |
| 125 | ..DoctorProbeRequest::default() |
| 126 | }; |
| 127 | assert!(search.should_probe_search()); |
| 128 | assert!(!search.should_check_updates()); |
| 129 | assert!(!search.should_probe_api(false)); |
| 130 | assert!(!search.should_probe_mcp()); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn cli_defaults_doctor_offline_and_keeps_json_incompatible_with_live_flags() { |
| 135 | let cli = |
| 136 | crate::Cli::try_parse_from(["codewhale-tui", "doctor"]).expect("parse default doctor"); |
| 137 | let Some(crate::Commands::Doctor(args)) = cli.command else { |
| 138 | panic!("expected doctor command"); |
| 139 | }; |
| 140 | assert!(!args.check_updates); |
| 141 | assert!(!args.probe_api); |
| 142 | assert!(!args.probe_local); |
| 143 | assert!(!args.probe_mcp); |
| 144 | assert!(!args.probe_search); |
| 145 | |
| 146 | let cli = crate::Cli::try_parse_from(["codewhale-tui", "doctor", "--probe-search"]) |
| 147 | .expect("parse search probe"); |
| 148 | let Some(crate::Commands::Doctor(args)) = cli.command else { |
| 149 | panic!("expected doctor command"); |
| 150 | }; |
| 151 | assert!(args.probe_search); |
| 152 | assert!(!args.probe_api); |
| 153 | assert!(!args.probe_local); |
| 154 | assert!(!args.probe_mcp); |
| 155 | |
| 156 | for output_flag in ["--json", "--context-json"] { |
| 157 | for live_flag in [ |
| 158 | "--check-updates", |
| 159 | "--probe-api", |
| 160 | "--probe-local", |
| 161 | "--probe-mcp", |
| 162 | "--probe-search", |
| 163 | ] { |
| 164 | assert!( |
| 165 | crate::Cli::try_parse_from(["codewhale-tui", "doctor", output_flag, live_flag,]) |
| 166 | .is_err(), |
| 167 | "{output_flag} unexpectedly accepted live flag {live_flag}" |
| 168 | ); |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | #[tokio::test] |
| 174 | async fn search_probe_counts_any_http_response_as_transport_only() { |
| 175 | use wiremock::matchers::{method, path}; |
| 176 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 177 | |
| 178 | let server = MockServer::start().await; |
| 179 | Mock::given(method("HEAD")) |
| 180 | .and(path("/")) |
| 181 | .respond_with(ResponseTemplate::new(401)) |
| 182 | .expect(1) |
| 183 | .mount(&server) |
| 184 | .await; |
| 185 | let config = crate::config::Config { |
| 186 | search: Some(crate::config::SearchConfig { |
| 187 | provider: Some(crate::config::SearchProvider::DuckDuckGo), |
| 188 | base_url: Some(server.uri()), |
| 189 | api_key: None, |
| 190 | }), |
| 191 | ..Default::default() |
| 192 | }; |
| 193 | |
| 194 | let report = doctor_search_probe( |
| 195 | &config, |
| 196 | DoctorProbeRequest { |
| 197 | probe_search: true, |
| 198 | ..DoctorProbeRequest::default() |
| 199 | }, |
| 200 | ) |
| 201 | .await; |
| 202 | let lines = doctor_search_probe_lines(&report).join("\n"); |
| 203 | |
| 204 | assert!(matches!( |
| 205 | report, |
| 206 | DoctorSearchProbeReport::Reachable { status: 401, .. } |
| 207 | )); |
| 208 | assert!(lines.contains("responded (HTTP 401)"), "{lines}"); |
| 209 | assert!(lines.contains("Transport only"), "{lines}"); |
| 210 | assert!(lines.contains("authentication and search results were not tested")); |
| 211 | assert!(!lines.contains("search successful")); |
| 212 | let requests = server.received_requests().await.expect("recorded probe"); |
| 213 | assert_eq!(requests.len(), 1); |
| 214 | assert!(requests[0].url.query().is_none()); |
| 215 | assert!(requests[0].body.is_empty()); |
| 216 | assert!(requests[0].headers.get("authorization").is_none()); |
| 217 | assert!(requests[0].headers.get("x-api-key").is_none()); |
| 218 | assert!(requests[0].headers.get("cookie").is_none()); |
| 219 | } |
| 220 | |
| 221 | #[tokio::test] |
| 222 | async fn search_probe_does_not_follow_redirects() { |
| 223 | use wiremock::matchers::{method, path}; |
| 224 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 225 | |
| 226 | let server = MockServer::start().await; |
| 227 | Mock::given(method("HEAD")) |
| 228 | .and(path("/")) |
| 229 | .respond_with(ResponseTemplate::new(302).insert_header("Location", "/redirected")) |
| 230 | .expect(1) |
| 231 | .mount(&server) |
| 232 | .await; |
| 233 | Mock::given(method("HEAD")) |
| 234 | .and(path("/redirected")) |
| 235 | .respond_with(ResponseTemplate::new(200)) |
| 236 | .expect(0) |
| 237 | .mount(&server) |
| 238 | .await; |
| 239 | let config = crate::config::Config { |
| 240 | search: Some(crate::config::SearchConfig { |
| 241 | provider: Some(crate::config::SearchProvider::Searxng), |
| 242 | base_url: Some(server.uri()), |
| 243 | api_key: None, |
| 244 | }), |
| 245 | ..Default::default() |
| 246 | }; |
| 247 | |
| 248 | let report = doctor_search_probe( |
| 249 | &config, |
| 250 | DoctorProbeRequest { |
| 251 | probe_search: true, |
| 252 | ..DoctorProbeRequest::default() |
| 253 | }, |
| 254 | ) |
| 255 | .await; |
| 256 | |
| 257 | assert!(matches!( |
| 258 | report, |
| 259 | DoctorSearchProbeReport::Reachable { status: 302, .. } |
| 260 | )); |
| 261 | } |
| 262 | |
| 263 | #[tokio::test] |
| 264 | async fn search_probe_respects_network_policy_without_contacting_the_authority() { |
| 265 | let config = crate::config::Config { |
| 266 | search: Some(crate::config::SearchConfig { |
| 267 | provider: Some(crate::config::SearchProvider::Searxng), |
| 268 | base_url: Some("https://search.example/private?token=secret".to_string()), |
| 269 | api_key: None, |
| 270 | }), |
| 271 | network: Some(crate::config::NetworkPolicyToml { |
| 272 | default: "allow".to_string(), |
| 273 | deny: vec!["search.example".to_string()], |
| 274 | ..Default::default() |
| 275 | }), |
| 276 | ..Default::default() |
| 277 | }; |
| 278 | |
| 279 | let report = doctor_search_probe( |
| 280 | &config, |
| 281 | DoctorProbeRequest { |
| 282 | probe_search: true, |
| 283 | ..DoctorProbeRequest::default() |
| 284 | }, |
| 285 | ) |
| 286 | .await; |
| 287 | let lines = doctor_search_probe_lines(&report).join("\n"); |
| 288 | |
| 289 | assert_eq!( |
| 290 | report, |
| 291 | DoctorSearchProbeReport::PolicyDenied { |
| 292 | authority: "https://search.example".to_string() |
| 293 | } |
| 294 | ); |
| 295 | assert!(!lines.contains("private")); |
| 296 | assert!(!lines.contains("token")); |
| 297 | assert!(!lines.contains("secret")); |
| 298 | } |
| 299 | |
| 300 | #[tokio::test] |
| 301 | async fn search_probe_skips_the_default_provider_when_web_search_is_disabled() { |
| 302 | let mut config = crate::config::Config::default(); |
| 303 | config |
| 304 | .set_feature("web_search", false) |
| 305 | .expect("known feature"); |
| 306 | |
| 307 | let report = doctor_search_probe( |
| 308 | &config, |
| 309 | DoctorProbeRequest { |
| 310 | probe_search: true, |
| 311 | ..DoctorProbeRequest::default() |
| 312 | }, |
| 313 | ) |
| 314 | .await; |
| 315 | |
| 316 | assert_eq!(report, DoctorSearchProbeReport::FeatureDisabled); |
| 317 | assert!( |
| 318 | doctor_search_probe_lines(&report) |
| 319 | .join("\n") |
| 320 | .contains("web_search feature is disabled") |
| 321 | ); |
| 322 | } |
| 323 | |
| 324 | #[test] |
| 325 | fn explicit_codewhale_home_owns_every_default_user_path() { |
| 326 | let _lock = crate::test_support::lock_test_env(); |
| 327 | let temp = tempfile::tempdir().expect("temp home"); |
| 328 | let home = temp.path().join("isolated-codewhale-home"); |
| 329 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 330 | let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"); |
| 331 | let _legacy_config = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"); |
| 332 | let _automations = crate::test_support::EnvVarGuard::remove("CODEWHALE_AUTOMATIONS_DIR"); |
| 333 | let _legacy_automations = crate::test_support::EnvVarGuard::remove("DEEPSEEK_AUTOMATIONS_DIR"); |
| 334 | let _tasks = crate::test_support::EnvVarGuard::remove("CODEWHALE_TASKS_DIR"); |
| 335 | let _legacy_tasks = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TASKS_DIR"); |
| 336 | let _runtime = crate::test_support::EnvVarGuard::remove("CODEWHALE_RUNTIME_DIR"); |
| 337 | let _legacy_runtime = crate::test_support::EnvVarGuard::remove("DEEPSEEK_RUNTIME_DIR"); |
| 338 | |
| 339 | let report = DoctorPathReport::resolve(None).expect("resolve doctor paths"); |
| 340 | let task_manager_root = crate::task_manager::default_tasks_dir(); |
| 341 | let runtime_config = crate::runtime_threads::RuntimeThreadManagerConfig::from_task_data_dir( |
| 342 | task_manager_root.clone(), |
| 343 | ); |
| 344 | let (secrets, legacy_secrets) = |
| 345 | codewhale_secrets::FileKeyringStore::default_paths_read_only().expect("secret paths"); |
| 346 | |
| 347 | assert_eq!(report.home, home); |
| 348 | assert_eq!(report.config, home.join("config.toml")); |
| 349 | assert_eq!(report.settings, home.join("settings.toml")); |
| 350 | assert_eq!(report.sessions, home.join("sessions")); |
| 351 | assert_eq!(report.logs, home.join("logs")); |
| 352 | assert_eq!(report.automations, home.join("automations")); |
| 353 | assert_eq!(report.task_manager_root, task_manager_root); |
| 354 | assert_eq!(report.task_manager_tasks, task_manager_root.join("tasks")); |
| 355 | assert_eq!( |
| 356 | report.task_manager_artifacts, |
| 357 | task_manager_root.join("artifacts") |
| 358 | ); |
| 359 | assert_eq!(report.runtime_store, runtime_config.data_dir); |
| 360 | assert_eq!( |
| 361 | report.runtime_events, |
| 362 | runtime_config.data_dir.join("events") |
| 363 | ); |
| 364 | assert_eq!( |
| 365 | report.personal_fleet_definitions, |
| 366 | crate::fleet::exact::personal_fleet_definitions_dir().expect("personal fleets") |
| 367 | ); |
| 368 | assert_eq!( |
| 369 | report.personal_fleet_agents, |
| 370 | crate::fleet::profile::personal_agent_profile_dir().expect("personal agents") |
| 371 | ); |
| 372 | assert_eq!(report.secrets, secrets); |
| 373 | assert_eq!(legacy_secrets, None); |
| 374 | assert_eq!(report.entries().len(), 14); |
| 375 | let json = serde_json::to_value(&report).expect("serialize path snapshot"); |
| 376 | for (label, path) in report.entries() { |
| 377 | assert_eq!( |
| 378 | json[label].as_str(), |
| 379 | Some(path.to_string_lossy().as_ref()), |
| 380 | "human and JSON path snapshots diverged for {label}" |
| 381 | ); |
| 382 | } |
| 383 | assert!( |
| 384 | !home.exists(), |
| 385 | "path reporting must not create the configured home" |
| 386 | ); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn explicit_relative_config_matches_the_canonical_loader_path() { |
| 391 | let relative = Path::new("fixtures/relative-doctor-config.toml"); |
| 392 | let expected = codewhale_config::resolve_config_path(Some(relative.to_path_buf())) |
| 393 | .expect("canonical config path"); |
| 394 | |
| 395 | let report = DoctorPathReport::resolve(Some(relative)).expect("resolve doctor paths"); |
| 396 | |
| 397 | assert_eq!(report.config, expected); |
| 398 | assert!(report.config.is_absolute()); |
| 399 | } |
| 400 | |
| 401 | #[test] |
| 402 | fn path_report_json_contains_no_secret_file_contents() { |
| 403 | let _lock = crate::test_support::lock_test_env(); |
| 404 | let temp = tempfile::tempdir().expect("temp home"); |
| 405 | let home = temp.path().join("isolated-codewhale-home"); |
| 406 | let secret_path = home.join("secrets").join("secrets.json"); |
| 407 | std::fs::create_dir_all(secret_path.parent().unwrap()).expect("secret dir fixture"); |
| 408 | let sentinel = "doctor-path-report-secret-sentinel"; |
| 409 | std::fs::write(&secret_path, sentinel).expect("secret fixture"); |
| 410 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 411 | |
| 412 | let report = DoctorPathReport::resolve(None).expect("resolve doctor paths"); |
| 413 | let json = serde_json::to_string(&report).expect("serialize path report"); |
| 414 | |
| 415 | assert!(!json.contains(sentinel)); |
| 416 | assert_eq!(std::fs::read_to_string(secret_path).unwrap(), sentinel); |
| 417 | } |
| 418 | |
| 419 | #[test] |
| 420 | fn human_and_json_backend_reports_never_include_secret_file_contents() { |
| 421 | let _lock = crate::test_support::lock_test_env(); |
| 422 | let temp = tempfile::tempdir().expect("temp home"); |
| 423 | let home = temp.path().join("isolated-codewhale-home"); |
| 424 | let secret_path = home.join("secrets").join("secrets.json"); |
| 425 | std::fs::create_dir_all(secret_path.parent().unwrap()).expect("secret dir fixture"); |
| 426 | let sentinel = "doctor-render-secret-sentinel"; |
| 427 | std::fs::write(&secret_path, format!("not-json:{sentinel}")).expect("secret fixture"); |
| 428 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str()); |
| 429 | let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file"); |
| 430 | |
| 431 | let diagnostic = codewhale_secrets::diagnose_secret_backend(); |
| 432 | let human = secret_backend_human_lines(&diagnostic).join("\n"); |
| 433 | let json = serde_json::to_string(&diagnostic).expect("serialize backend diagnostic"); |
| 434 | |
| 435 | assert!(!human.contains(sentinel)); |
| 436 | assert!(!json.contains(sentinel)); |
| 437 | assert_eq!( |
| 438 | std::fs::read_to_string(secret_path).unwrap(), |
| 439 | format!("not-json:{sentinel}") |
| 440 | ); |
| 441 | } |
| 442 | |
| 443 | #[test] |
| 444 | fn structural_url_authority_omits_every_secret_capable_component() { |
| 445 | let sentinels = [ |
| 446 | "URL-USER-SENTINEL", |
| 447 | "URL-PASSWORD-SENTINEL", |
| 448 | "URL-PATH-SENTINEL", |
| 449 | "URL-QUERY-KEY-SENTINEL", |
| 450 | "URL-QUERY-VALUE-SENTINEL", |
| 451 | "URL-FRAGMENT-SENTINEL", |
| 452 | ]; |
| 453 | let raw = format!( |
| 454 | "https://{}:{}@example.invalid:8443/{}/child?{}={}#{}", |
| 455 | sentinels[0], sentinels[1], sentinels[2], sentinels[3], sentinels[4], sentinels[5] |
| 456 | ); |
| 457 | |
| 458 | let authority = structural_url_authority(&raw); |
| 459 | |
| 460 | assert_eq!(authority, "https://example.invalid:8443"); |
| 461 | for sentinel in sentinels { |
| 462 | assert!(!authority.contains(sentinel)); |
| 463 | } |
| 464 | assert_eq!( |
| 465 | structural_url_authority("http://[::1]:9000/private?token=review-secret"), |
| 466 | "http://[::1]:9000" |
| 467 | ); |
| 468 | } |
| 469 | |
| 470 | #[test] |
| 471 | fn credential_shaped_config_values_are_flagged_by_key_name_only() { |
| 472 | // Fixture tokens stay low-entropy on purpose: realistic random strings |
| 473 | // trip secret scanners (GitGuardian flagged the originals as live credentials). |
| 474 | let raw = r#" |
| 475 | # comment with sk-not-a-real-line |
| 476 | model = "deepseek-v4-flash" |
| 477 | base_url = "https://api.moonshot.ai/kimi-code/v1" |
| 478 | chatgpt_access_token = "eyJ0000000000000000000000000" |
| 479 | moonshot_api_key = "[redacted]" |
| 480 | provider_api_key = "sk-test0000000000000000" |
| 481 | workspace_token_note = "short" |
| 482 | random_id = "0123456789abcdef0123456789abcdef" |
| 483 | "#; |
| 484 | let flagged = super::config_credential_shaped_keys(raw); |
| 485 | assert_eq!(flagged, vec!["chatgpt_access_token", "provider_api_key"]); |
| 486 | } |
| 487 | |
| 488 | #[test] |
| 489 | fn credential_scan_ignores_urls_models_and_redacted_entries() { |
| 490 | let raw = r#" |
| 491 | model = "kimi-k3-instruct-preview-2026" |
| 492 | endpoint = "https://example.com/v1?key=nope" |
| 493 | api_key = "[redacted]" |
| 494 | "#; |
| 495 | assert!(super::config_credential_shaped_keys(raw).is_empty()); |
| 496 | } |
| 497 |