返回 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 }
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
264 lines RUST