返回 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).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
497 lines RUST