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