返回 CodeWhale
tests.rs
根目录 / crates / tui / src / main / tests.rs
1 use super::*;
2 use clap::Parser;
3 use std::fs;
4 use tempfile::TempDir;
5
6 #[test]
7 fn offline_doctor_loads_never_materialize_secret_environment_overrides() {
8 let _guard = crate::test_support::lock_test_env();
9 let temp = tempfile::TempDir::new().expect("tempdir");
10 let config_path = temp.path().join("config.toml");
11 fs::write(&config_path, "").expect("empty config");
12 let _home = crate::test_support::EnvVarGuard::set(
13 "CODEWHALE_HOME",
14 temp.path().join("home").as_os_str(),
15 );
16 let _profile = crate::test_support::EnvVarGuard::remove("CODEWHALE_PROFILE");
17 let _legacy_profile = crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROFILE");
18 let _managed = crate::test_support::EnvVarGuard::remove("CODEWHALE_MANAGED_CONFIG_PATH");
19 let _legacy_managed = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MANAGED_CONFIG_PATH");
20 let _requirements = crate::test_support::EnvVarGuard::remove("CODEWHALE_REQUIREMENTS_PATH");
21 let _legacy_requirements =
22 crate::test_support::EnvVarGuard::remove("DEEPSEEK_REQUIREMENTS_PATH");
23 let _headers = crate::test_support::EnvVarGuard::set(
24 "CODEWHALE_HTTP_HEADERS",
25 "Authorization=doctor-offline-header-sentinel",
26 );
27 let _legacy_headers = crate::test_support::EnvVarGuard::set(
28 "DEEPSEEK_HTTP_HEADERS",
29 "Authorization=doctor-offline-legacy-header-sentinel",
30 );
31 let _sandbox = crate::test_support::EnvVarGuard::set(
32 "CODEWHALE_SANDBOX_API_KEY",
33 "doctor-offline-sandbox-sentinel",
34 );
35 let _legacy_sandbox = crate::test_support::EnvVarGuard::set(
36 "DEEPSEEK_SANDBOX_API_KEY",
37 "doctor-offline-legacy-sandbox-sentinel",
38 );
39 let _search = crate::test_support::EnvVarGuard::set(
40 "CODEWHALE_SEARCH_API_KEY",
41 "doctor-offline-search-sentinel",
42 );
43 let _legacy_search = crate::test_support::EnvVarGuard::set(
44 "DEEPSEEK_SEARCH_API_KEY",
45 "doctor-offline-legacy-search-sentinel",
46 );
47 let _base_url = crate::test_support::EnvVarGuard::set(
48 "CODEWHALE_BASE_URL",
49 "https://safe-doctor.example:9443/v1",
50 );
51 let _allow_shell = crate::test_support::EnvVarGuard::set("CODEWHALE_ALLOW_SHELL", "false");
52 let config_arg = config_path.to_string_lossy().into_owned();
53
54 for suffix in [
55 Vec::<&str>::new(),
56 vec!["--json"],
57 vec!["--context-json"],
58 vec!["--check-updates"],
59 vec!["--probe-mcp"],
60 ] {
61 let mut argv = vec![
62 "codewhale-tui".to_string(),
63 "--config".to_string(),
64 config_arg.clone(),
65 "doctor".to_string(),
66 ];
67 argv.extend(suffix.iter().copied().map(str::to_string));
68 let cli = Cli::try_parse_from(argv).expect("offline doctor CLI");
69 let Some(Commands::Doctor(args)) = cli.command.as_ref() else {
70 panic!("expected doctor command");
71 };
72 let config = load_doctor_config_from_cli(&cli, args).expect("offline doctor config");
73 assert!(config.http_headers.is_none());
74 assert!(config.sandbox_api_key.is_none());
75 assert!(
76 config
77 .search
78 .as_ref()
79 .and_then(|search| search.api_key.as_deref())
80 .is_none()
81 );
82 assert_eq!(
83 config.base_url.as_deref(),
84 Some("https://safe-doctor.example:9443/v1")
85 );
86 assert_eq!(config.allow_shell, Some(false));
87 let rendered = format!("{config:?}");
88 for sentinel in [
89 "doctor-offline-header-sentinel",
90 "doctor-offline-legacy-header-sentinel",
91 "doctor-offline-sandbox-sentinel",
92 "doctor-offline-legacy-sandbox-sentinel",
93 "doctor-offline-search-sentinel",
94 "doctor-offline-legacy-search-sentinel",
95 ] {
96 assert!(
97 !rendered.contains(sentinel),
98 "{suffix:?} retained {sentinel}"
99 );
100 }
101 }
102
103 for probe in ["--probe-api", "--probe-local"] {
104 let cli = Cli::try_parse_from(["codewhale-tui", "--config", &config_arg, "doctor", probe])
105 .expect("live doctor CLI");
106 let Some(Commands::Doctor(args)) = cli.command.as_ref() else {
107 panic!("expected doctor command");
108 };
109 let config = load_doctor_config_from_cli(&cli, args).expect("live doctor config");
110 assert!(config.http_headers.is_some(), "{probe} keeps live headers");
111 assert_eq!(
112 config.sandbox_api_key.as_deref(),
113 Some("doctor-offline-sandbox-sentinel")
114 );
115 assert_eq!(
116 config
117 .search
118 .as_ref()
119 .and_then(|search| search.api_key.as_deref()),
120 Some("doctor-offline-search-sentinel")
121 );
122 }
123 }
124
125 fn parse_cli(args: &[&str]) -> Cli {
126 Cli::try_parse_from(args).expect("CLI args should parse")
127 }
128
129 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
130 McpServerConfig {
131 command: command.map(String::from),
132 args: args.iter().map(|s| s.to_string()).collect(),
133 env: std::collections::HashMap::new(),
134 cwd: None,
135 url: url.map(String::from),
136 transport: None,
137 connect_timeout: None,
138 execute_timeout: None,
139 read_timeout: None,
140 disabled: false,
141 enabled: true,
142 required: false,
143 enabled_tools: Vec::new(),
144 disabled_tools: Vec::new(),
145 headers: std::collections::HashMap::new(),
146 env_headers: std::collections::HashMap::new(),
147 bearer_token_env_var: None,
148 scopes: Vec::new(),
149 oauth: None,
150 oauth_resource: None,
151 reviewed_plugin: None,
152 }
153 }
154
155 #[test]
156 fn doctor_does_not_expand_mcp_environment_placeholders() {
157 let _lock = crate::test_support::lock_test_env();
158 let _missing = crate::test_support::EnvVarGuard::remove("CODEWHALE_DOCTOR_MCP_MISSING_PATH");
159 let mut server = make_server(Some("codewhale-mcp-command"), &[], None);
160 server.env.insert(
161 "PATH".to_string(),
162 "do-not-leak-${CODEWHALE_DOCTOR_MCP_MISSING_PATH}-also-secret".to_string(),
163 );
164
165 let status = doctor_check_mcp_server(&server);
166 assert!(matches!(status, McpServerDoctorStatus::Ok(_)));
167 let report = doctor_mcp_server_json("invalid-env", &server);
168 assert_eq!(report["checks"]["command"]["status"], "not_checked");
169 let serialized = report.to_string();
170 assert!(!serialized.contains("do-not-leak"));
171 assert!(!serialized.contains("also-secret"));
172 assert!(!serialized.contains("CODEWHALE_DOCTOR_MCP_MISSING_PATH"));
173 }
174 #[test]
175 fn doctor_does_not_resolve_or_echo_command_environment() {
176 let sentinel = "MCP-PATH-VALUE-SENTINEL";
177 let mut server = make_server(Some("mcp-command"), &[], None);
178 server.env.insert("PATH".to_string(), sentinel.to_string());
179
180 assert!(matches!(
181 doctor_check_mcp_server(&server),
182 McpServerDoctorStatus::Ok(_)
183 ));
184 let report = doctor_mcp_server_json("path-only", &server);
185 assert_eq!(report["checks"]["command"]["status"], "not_checked");
186 assert!(!report.to_string().contains(sentinel));
187 }
188 #[test]
189 fn doctor_mcp_reports_omit_missing_command_value() {
190 let sentinel = "MCP-MISSING-COMMAND-SENTINEL";
191 let server = make_server(Some(sentinel), &[], None);
192 let status = doctor_check_mcp_server(&server);
193 let human = status.detail().to_string();
194 let json = doctor_mcp_server_json("missing-command", &server).to_string();
195
196 assert!(!human.contains(sentinel));
197 assert!(!json.contains(sentinel));
198 }
199 #[test]
200 fn doctor_mcp_reports_redact_url_argv_and_env_values() {
201 let url_sentinels = [
202 "MCP-URL-USER-SENTINEL",
203 "MCP-URL-PASSWORD-SENTINEL",
204 "MCP-URL-PATH-SENTINEL",
205 "MCP-URL-QUERY-KEY-SENTINEL",
206 "MCP-URL-QUERY-VALUE-SENTINEL",
207 "MCP-URL-FRAGMENT-SENTINEL",
208 ];
209 let url = format!(
210 "https://{}:{}@example.invalid:8443/{}/mcp?{}={}#{}",
211 url_sentinels[0],
212 url_sentinels[1],
213 url_sentinels[2],
214 url_sentinels[3],
215 url_sentinels[4],
216 url_sentinels[5]
217 );
218 let url_server = make_server(None, &[], Some(&url));
219 let url_status = doctor_check_mcp_server(&url_server);
220 let url_human = format!("configuration: {}", url_status.detail());
221 let url_json = doctor_mcp_server_json("url-server", &url_server).to_string();
222
223 for sentinel in url_sentinels {
224 assert!(!url_human.contains(sentinel));
225 assert!(!url_json.contains(sentinel));
226 }
227 assert!(url_human.contains("https://example.invalid:8443"));
228 assert!(!url_human.contains("/mcp"));
229 assert!(!url_human.contains('?'));
230 assert!(!url_human.contains('#'));
231
232 let executable = std::env::current_exe().expect("current test executable");
233 let executable = executable.to_string_lossy();
234 let argv_sentinels = [
235 "MCP-BEARER-ARG-SENTINEL",
236 "MCP-TOKEN-VALUE-SENTINEL",
237 "MCP-OTHER-ARG-SENTINEL",
238 "MCP-ENV-VALUE-SENTINEL",
239 ];
240 let mut stdio_server = make_server(
241 Some(&executable),
242 &[
243 "--bearer=MCP-BEARER-ARG-SENTINEL",
244 "--token",
245 "MCP-TOKEN-VALUE-SENTINEL",
246 "MCP-OTHER-ARG-SENTINEL",
247 ],
248 None,
249 );
250 stdio_server.env.insert(
251 "MCP_TOKEN".to_string(),
252 "MCP-ENV-VALUE-SENTINEL".to_string(),
253 );
254 let stdio_status = doctor_check_mcp_server(&stdio_server);
255 let stdio_human = format!("configuration: {}", stdio_status.detail());
256 let stdio_json = doctor_mcp_server_json("stdio-server", &stdio_server).to_string();
257
258 for sentinel in argv_sentinels {
259 assert!(!stdio_human.contains(sentinel));
260 assert!(!stdio_json.contains(sentinel));
261 }
262 assert_eq!(
263 doctor_mcp_server_json("stdio-server", &stdio_server)["args_count"],
264 4
265 );
266 assert_eq!(
267 doctor_mcp_server_json("stdio-server", &stdio_server)["env_count"],
268 1
269 );
270 }
271 #[test]
272 fn doctor_provider_json_redacts_every_credential_url_to_its_authority() {
273 let mut providers = crate::config::ApiProvider::all().to_vec();
274 providers.push(crate::config::ApiProvider::DeepseekCN);
275
276 for provider in providers {
277 let config = Config {
278 provider: Some(provider.as_str().to_string()),
279 ..Config::default()
280 };
281 let report = doctor_provider_model_report_json(&config);
282 for field in ["credential_url", "credential_docs_url"] {
283 let Some(value) = report["auth"][field].as_str() else {
284 continue;
285 };
286 assert_eq!(
287 value,
288 crate::doctor::structural_url_authority(value),
289 "{provider:?} {field} must be authority-only"
290 );
291 assert!(
292 value
293 .chars()
294 .all(|character| !matches!(character, '?' | '#' | '@')),
295 "{provider:?} {field} must omit credential-bearing URL components"
296 );
297 }
298 }
299 }
300 #[test]
301 fn doctor_provider_url_reports_omit_secret_capable_components() {
302 let sentinels = [
303 "PROVIDER-URL-USER-SENTINEL",
304 "PROVIDER-URL-PASSWORD-SENTINEL",
305 "PROVIDER-URL-PATH-SENTINEL",
306 "PROVIDER-URL-QUERY-KEY-SENTINEL",
307 "PROVIDER-URL-QUERY-VALUE-SENTINEL",
308 "PROVIDER-URL-FRAGMENT-SENTINEL",
309 ];
310 let base_url = format!(
311 "https://{}:{}@provider.example.invalid:9443/{}/v1?{}={}#{}",
312 sentinels[0], sentinels[1], sentinels[2], sentinels[3], sentinels[4], sentinels[5]
313 );
314 let config = Config {
315 base_url: Some(base_url),
316 ..Config::default()
317 };
318 let target = doctor_api_target(&config);
319 let human = format!(
320 "base_url: {}",
321 crate::doctor::structural_url_authority(&target.base_url)
322 );
323 let json = serde_json::json!({
324 "base_url": crate::doctor::structural_url_authority(&target.base_url),
325 "route": doctor_route_report(&config),
326 })
327 .to_string();
328
329 assert_eq!(human, "base_url: https://provider.example.invalid:9443");
330 for sentinel in sentinels {
331 assert!(!human.contains(sentinel));
332 assert!(!json.contains(sentinel));
333 }
334 }
335 #[test]
336 fn doctor_startup_skips_workspace_dotenv_loading() {
337 use std::cell::Cell;
338
339 for args in [
340 vec!["codewhale", "doctor"],
341 vec!["codewhale", "doctor", "--json"],
342 vec!["codewhale", "doctor", "--context-json"],
343 vec!["codewhale", "doctor", "--probe-mcp"],
344 vec!["codewhale", "doctor", "--check-updates"],
345 ] {
346 let phase = Cell::new(0);
347 let (_cli, command) = prepare_cli_startup(
348 parse_cli(&args),
349 || {
350 assert_eq!(phase.get(), 0);
351 phase.set(1);
352 },
353 || phase.set(2),
354 );
355
356 assert_eq!(phase.get(), 1, "doctor must not load workspace dotenv");
357 assert!(matches!(command, Some(Commands::Doctor(_))));
358 }
359 }
360 #[test]
361 fn explicit_doctor_api_probes_may_load_workspace_dotenv_credentials() {
362 use std::cell::Cell;
363
364 for args in [
365 ["codewhale", "doctor", "--probe-api"],
366 ["codewhale", "doctor", "--probe-local"],
367 ] {
368 let phase = Cell::new(0);
369 let (_cli, command) = prepare_cli_startup(
370 parse_cli(&args),
371 || phase.set(1),
372 || {
373 assert_eq!(phase.get(), 1);
374 phase.set(2);
375 },
376 );
377
378 assert_eq!(
379 phase.get(),
380 2,
381 "explicit API probes may resolve dotenv credentials"
382 );
383 assert!(matches!(command, Some(Commands::Doctor(_))));
384 }
385 }
386 #[test]
387 fn hosted_provider_does_not_probe_without_explicit_opt_in() {
388 assert!(!doctor_should_probe_api(
389 crate::config::ApiProvider::Deepseek,
390 "https://api.deepseek.com/beta",
391 crate::doctor::DoctorProbeRequest::default(),
392 ));
393 assert!(doctor_should_probe_api(
394 crate::config::ApiProvider::Deepseek,
395 "https://api.deepseek.com/beta",
396 crate::doctor::DoctorProbeRequest {
397 probe_api: true,
398 ..crate::doctor::DoctorProbeRequest::default()
399 },
400 ));
401 }
402 #[test]
403 fn resolve_api_key_source_does_not_clone_openai_codex_env_tokens() {
404 let _guard = crate::test_support::lock_test_env();
405 let sentinel = "OPENAI-CODEX-ACCESS-TOKEN-SENTINEL";
406 let _token = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", sentinel);
407 let _legacy_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
408 let config = Config {
409 provider: Some("openai-codex".to_string()),
410 ..Config::default()
411 };
412
413 let source = resolve_api_key_source(&config);
414 let report = doctor_provider_model_report_json(&config).to_string();
415
416 assert_eq!(source, ApiKeySource::OAuth);
417 assert_eq!(doctor_api_key_source_label(source), "oauth_unprobed");
418 assert!(!report.contains(sentinel));
419 }
420 #[test]
421 fn resolve_api_key_source_does_not_inspect_dispatcher_source_or_value() {
422 let _guard = crate::test_support::lock_test_env();
423 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
424 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
425 unsafe {
426 std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
427 std::env::set_var("DEEPSEEK_API_KEY_SOURCE", "keyring");
428 }
429 let cfg = Config::default();
430 let source = resolve_api_key_source(&cfg);
431 match prev {
432 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
433 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
434 }
435 match prev_source {
436 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
437 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
438 }
439 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
440 }
441 #[test]
442 fn resolve_api_key_source_does_not_inspect_provider_env_values() {
443 let _guard = crate::test_support::lock_test_env();
444 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
445 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
446 unsafe {
447 std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
448 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
449 }
450 let cfg = Config::default();
451 let source = resolve_api_key_source(&cfg);
452 match prev {
453 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
454 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
455 }
456 match prev_source {
457 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
458 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
459 }
460 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
461 }
462 #[test]
463 fn resolve_api_key_source_does_not_open_standalone_secret_store() {
464 let _lock = crate::test_support::lock_test_env();
465 let temp = TempDir::new().expect("temp home");
466 let codewhale_home = temp.path().join("codewhale-home");
467 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
468 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
469 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
470 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
471 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
472 let secret_path = codewhale_home.join("secrets").join("secrets.json");
473 std::fs::create_dir_all(secret_path.parent().unwrap()).expect("secret fixture dir");
474 let sentinel = "not-json:doctor-must-not-read-this-secret";
475 std::fs::write(&secret_path, sentinel).expect("secret fixture");
476
477 assert_eq!(
478 resolve_credential_diagnostic(&Config::default()),
479 CredentialDiagnostic::new(
480 ApiKeySource::SecretStoreUnprobed,
481 CredentialAvailability::NotProbed,
482 )
483 );
484 assert_eq!(std::fs::read_to_string(secret_path).unwrap(), sentinel);
485 }
486 #[test]
487 fn resolve_api_key_source_does_not_probe_system_keyring() {
488 let _lock = crate::test_support::lock_test_env();
489 let temp = TempDir::new().expect("temp home");
490 let _home = crate::test_support::EnvVarGuard::set(
491 "CODEWHALE_HOME",
492 temp.path().join("codewhale-home").as_os_str(),
493 );
494 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "system");
495 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
496 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
497
498 assert_eq!(
499 resolve_credential_diagnostic(&Config::default()),
500 CredentialDiagnostic::new(
501 ApiKeySource::SecretStoreUnprobed,
502 CredentialAvailability::NotProbed,
503 )
504 );
505 }
506
507 #[test]
508 fn credential_diagnostic_distinguishes_literal_from_empty_config_keys() {
509 let literal = Config {
510 api_key: Some("TEST-LITERAL-CONFIG-KEY".to_string()),
511 ..Config::default()
512 };
513 assert_eq!(
514 resolve_credential_diagnostic(&literal),
515 CredentialDiagnostic::new(
516 ApiKeySource::ConfigDeclared,
517 CredentialAvailability::Present,
518 )
519 );
520 assert!(doctor_has_credentials_or_local_runtime(&literal));
521
522 let mut providers = crate::config::ProvidersConfig::default();
523 providers.openai.api_key = Some(" ".to_string());
524 let empty = Config {
525 provider: Some("openai".to_string()),
526 providers: Some(providers),
527 ..Config::default()
528 };
529 assert_eq!(
530 resolve_credential_diagnostic(&empty),
531 CredentialDiagnostic::new(
532 ApiKeySource::SecretStoreUnprobed,
533 CredentialAvailability::NotProbed,
534 )
535 );
536 assert!(!doctor_has_credentials_or_local_runtime(&empty));
537
538 let empty_root = Config {
539 api_key: Some(String::new()),
540 ..Config::default()
541 };
542 assert_eq!(
543 resolve_credential_diagnostic(&empty_root).source,
544 ApiKeySource::SecretStoreUnprobed
545 );
546 assert!(!doctor_has_credentials_or_local_runtime(&empty_root));
547 }
548
549 #[test]
550 fn credential_diagnostic_treats_sentinel_as_unprobed_store_not_config() {
551 let _lock = crate::test_support::lock_test_env();
552 let temp = TempDir::new().expect("temp home");
553 let codewhale_home = temp.path().join("codewhale-home");
554 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
555 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
556 let config = Config {
557 api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
558 ..Config::default()
559 };
560
561 assert_eq!(
562 resolve_credential_diagnostic(&config),
563 CredentialDiagnostic::new(
564 ApiKeySource::SecretStoreUnprobed,
565 CredentialAvailability::NotProbed,
566 )
567 );
568 assert!(!doctor_has_credentials_or_local_runtime(&config));
569 assert!(!codewhale_home.join("secrets/secrets.json").exists());
570
571 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
572 let mut providers = crate::config::ProvidersConfig::default();
573 providers.openai.api_key = Some(sentinel.to_string());
574 let official = Config {
575 provider: Some("openai".to_string()),
576 providers: Some(providers),
577 ..Config::default()
578 };
579 assert_eq!(
580 resolve_credential_diagnostic(&official),
581 CredentialDiagnostic::new(
582 ApiKeySource::SecretStoreUnprobed,
583 CredentialAvailability::NotProbed,
584 )
585 );
586 }
587 }
588
589 #[test]
590 fn unavailable_sentinel_routes_stay_distinct_from_unknown_and_unprobed() {
591 let _lock = crate::test_support::lock_test_env();
592 let temp = TempDir::new().expect("temp home");
593 let workspace = temp.path().join("workspace");
594 std::fs::create_dir_all(&workspace).expect("workspace");
595 let _home =
596 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path().join("codewhale-home"));
597
598 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
599 let mut custom = std::collections::HashMap::new();
600 custom.insert(
601 "sentinel-route".to_string(),
602 crate::config::ProviderConfig {
603 kind: Some("openai-compatible".to_string()),
604 base_url: Some("https://gateway.example.test/v1".to_string()),
605 model: Some("test-model".to_string()),
606 api_key: Some(sentinel.to_string()),
607 ..Default::default()
608 },
609 );
610 let named_custom = Config {
611 provider: Some("sentinel-route".to_string()),
612 providers: Some(crate::config::ProvidersConfig {
613 custom,
614 ..Default::default()
615 }),
616 ..Config::default()
617 };
618 assert_eq!(
619 resolve_credential_diagnostic(&named_custom),
620 CredentialDiagnostic::new(
621 ApiKeySource::SecretStoreUnavailable,
622 CredentialAvailability::Unavailable,
623 )
624 );
625 assert!(!doctor_has_credentials_or_local_runtime(&named_custom));
626 let setup = doctor_setup_report_json(&named_custom, &workspace);
627 assert_eq!(setup["credential"]["source"], "secret_store_unavailable");
628 assert_eq!(setup["credential"]["availability"], "unavailable");
629 assert_eq!(setup["credential"]["ready"], false);
630 assert_eq!(
631 setup["provider_model"]["auth"]["availability"],
632 "unavailable"
633 );
634 assert_eq!(
635 setup["operate_fleet"]["provider"]["auth"]["availability"],
636 "unavailable"
637 );
638 assert_eq!(setup["operate_fleet"]["ready"], false);
639 assert_eq!(
640 doctor_route_report(&named_custom)["auth"]["availability"],
641 "unavailable"
642 );
643 }
644
645 let mut providers = crate::config::ProvidersConfig::default();
646 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
647 providers.openrouter.api_key = Some(crate::config::API_KEYRING_SENTINEL.to_string());
648 let custom_endpoint = Config {
649 provider: Some("openrouter".to_string()),
650 providers: Some(providers),
651 ..Config::default()
652 };
653 assert_eq!(
654 resolve_credential_diagnostic(&custom_endpoint),
655 CredentialDiagnostic::new(
656 ApiKeySource::SecretStoreUnavailable,
657 CredentialAvailability::Unavailable,
658 )
659 );
660
661 let mut custom = std::collections::HashMap::new();
662 custom.insert(
663 "empty-route".to_string(),
664 crate::config::ProviderConfig {
665 kind: Some("openai-compatible".to_string()),
666 base_url: Some("https://empty.example.test/v1".to_string()),
667 model: Some("test-model".to_string()),
668 api_key: Some(" ".to_string()),
669 ..Default::default()
670 },
671 );
672 let empty_custom = Config {
673 provider: Some("empty-route".to_string()),
674 providers: Some(crate::config::ProvidersConfig {
675 custom,
676 ..Default::default()
677 }),
678 ..Config::default()
679 };
680 assert_eq!(
681 resolve_credential_diagnostic(&empty_custom),
682 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
683 );
684 }
685
686 #[test]
687 fn sentinel_placeholders_never_become_attemptable_routes_or_metered_evidence() {
688 let _lock = crate::test_support::lock_test_env();
689 let temp = TempDir::new().expect("isolated credential home");
690 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
691 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
692 let _xai = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
693 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
694 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
695 let _mimo_mode = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_MODE");
696 let _mimo_base = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_BASE_URL");
697 let _mimo_plan = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
698 let _mimo_plan_alias = crate::test_support::EnvVarGuard::remove("MIMO_TOKEN_PLAN_API_KEY");
699 let _mimo_key = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_API_KEY");
700 let _xiaomi_key = crate::test_support::EnvVarGuard::remove("XIAOMI_API_KEY");
701 let _mimo_key_alias = crate::test_support::EnvVarGuard::remove("MIMO_API_KEY");
702
703 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
704 let named_custom = Config {
705 provider: Some("sentinel-route".to_string()),
706 providers: Some(crate::config::ProvidersConfig {
707 custom: std::collections::HashMap::from([(
708 "sentinel-route".to_string(),
709 crate::config::ProviderConfig {
710 kind: Some("openai-compatible".to_string()),
711 base_url: Some("https://gateway.example.test/v1".to_string()),
712 model: Some("sentinel-model".to_string()),
713 api_key: Some(sentinel.to_string()),
714 ..Default::default()
715 },
716 )]),
717 ..Default::default()
718 }),
719 ..Default::default()
720 };
721 assert_eq!(
722 crate::provider_readiness::credential_state_for_provider(
723 &named_custom,
724 crate::config::ApiProvider::Custom,
725 ),
726 crate::provider_readiness::CredentialState::MissingKey
727 );
728 let readiness = crate::provider_readiness::resolve_for_model(
729 &named_custom,
730 crate::config::ApiProvider::Custom,
731 "sentinel-model",
732 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
733 );
734 assert_eq!(
735 readiness,
736 crate::provider_readiness::ResolvedProviderReadiness::MissingKey
737 );
738 assert!(!readiness.can_attempt());
739 assert!(
740 crate::model_inventory::ModelInventory::from_config(&named_custom)
741 .candidates
742 .iter()
743 .all(|candidate| candidate.provider != crate::config::ApiProvider::Custom)
744 );
745
746 let xai = Config {
747 provider: Some("xai".to_string()),
748 providers: Some(crate::config::ProvidersConfig {
749 xai: crate::config::ProviderConfig {
750 api_key: Some(sentinel.to_string()),
751 ..Default::default()
752 },
753 ..Default::default()
754 }),
755 ..Default::default()
756 };
757 assert_eq!(
758 crate::provider_readiness::credential_state_for_provider(
759 &xai,
760 crate::config::ApiProvider::Xai,
761 ),
762 crate::provider_readiness::CredentialState::MissingKey
763 );
764
765 let mut xiaomi_providers = crate::config::ProvidersConfig::default();
766 xiaomi_providers.xiaomi_mimo.api_key = Some(sentinel.to_string());
767 let xiaomi = Config {
768 providers: Some(xiaomi_providers),
769 ..Default::default()
770 };
771 assert_eq!(
772 crate::route_billing::for_route(&xiaomi, crate::config::ApiProvider::XiaomiMimo),
773 crate::route_billing::BillingPresentation::Subscription("MiMo token plan")
774 );
775 }
776 }
777
778 #[test]
779 fn sentinel_diagnostic_yields_to_route_bound_env_or_auth_declaration() {
780 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
781 let mut providers = crate::config::ProvidersConfig::default();
782 providers.openai.api_key = Some(sentinel.to_string());
783 providers.openai.api_key_env = Some("OFFICIAL_SENTINEL_ROUTE_KEY".to_string());
784 let official = Config {
785 provider: Some("openai".to_string()),
786 providers: Some(providers),
787 ..Config::default()
788 };
789 assert_eq!(
790 resolve_credential_diagnostic(&official),
791 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed,)
792 );
793
794 let mut custom = std::collections::HashMap::new();
795 custom.insert(
796 "sentinel-route".to_string(),
797 crate::config::ProviderConfig {
798 kind: Some("openai-compatible".to_string()),
799 base_url: Some("https://gateway.example.test/v1".to_string()),
800 model: Some("test-model".to_string()),
801 api_key: Some(sentinel.to_string()),
802 api_key_env: Some("CUSTOM_SENTINEL_ROUTE_KEY".to_string()),
803 ..Default::default()
804 },
805 );
806 let named_custom = Config {
807 provider: Some("sentinel-route".to_string()),
808 providers: Some(crate::config::ProvidersConfig {
809 custom,
810 ..Default::default()
811 }),
812 ..Config::default()
813 };
814 assert_eq!(
815 resolve_credential_diagnostic(&named_custom),
816 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed,)
817 );
818
819 let mut external = std::collections::HashMap::new();
820 external.insert(
821 "external-sentinel-route".to_string(),
822 crate::config::ProviderConfig {
823 kind: Some("openai-compatible".to_string()),
824 base_url: Some("https://external.example.test/v1".to_string()),
825 model: Some("test-model".to_string()),
826 api_key: Some(sentinel.to_string()),
827 auth: Some(codewhale_config::ProviderAuthSourceToml {
828 source: codewhale_config::AuthSourceKind::Command,
829 command: vec!["MUST-NOT-RUN".to_string()],
830 timeout_ms: None,
831 secret_id: None,
832 }),
833 ..Default::default()
834 },
835 );
836 let named_external = Config {
837 provider: Some("external-sentinel-route".to_string()),
838 providers: Some(crate::config::ProvidersConfig {
839 custom: external,
840 ..Default::default()
841 }),
842 ..Config::default()
843 };
844 assert_eq!(
845 resolve_credential_diagnostic(&named_external),
846 CredentialDiagnostic::new(
847 ApiKeySource::ExternalAuthDeclared,
848 CredentialAvailability::NotProbed,
849 )
850 );
851 }
852 }
853
854 #[test]
855 fn credential_declarations_do_not_certify_setup_or_fleet_readiness() {
856 let _lock = crate::test_support::lock_test_env();
857 let temp = TempDir::new().expect("temp home");
858 let codewhale_home = temp.path().join("codewhale-home");
859 let workspace = temp.path().join("workspace");
860 std::fs::create_dir_all(&workspace).expect("workspace");
861 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
862 let _declared_value =
863 crate::test_support::EnvVarGuard::set("TEST_DECLARED_API_KEY", "MUST-NOT-BE-READ");
864
865 let mut providers = crate::config::ProvidersConfig::default();
866 providers.openai.api_key_env = Some("TEST_DECLARED_API_KEY".to_string());
867 let env_config = Config {
868 provider: Some("openai".to_string()),
869 providers: Some(providers),
870 ..Config::default()
871 };
872 let env_diagnostic = resolve_credential_diagnostic(&env_config);
873 assert_eq!(env_diagnostic.source, ApiKeySource::EnvDeclared);
874 assert_eq!(
875 env_diagnostic.availability,
876 CredentialAvailability::NotProbed
877 );
878 let env_setup = doctor_setup_report_json(&env_config, &workspace);
879 assert_eq!(env_setup["credential"]["ready"], false);
880 assert_eq!(
881 env_setup["operate_fleet"]["provider"]["auth"]["present_or_local"],
882 false
883 );
884 assert_eq!(env_setup["operate_fleet"]["ready"], false);
885 assert!(!env_setup.to_string().contains("MUST-NOT-BE-READ"));
886
887 let mut providers = crate::config::ProvidersConfig::default();
888 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
889 source: codewhale_config::AuthSourceKind::Command,
890 command: vec!["MUST-NOT-RUN".to_string()],
891 timeout_ms: None,
892 secret_id: None,
893 });
894 let external_config = Config {
895 provider: Some("openai".to_string()),
896 providers: Some(providers),
897 ..Config::default()
898 };
899 let external_diagnostic = resolve_credential_diagnostic(&external_config);
900 assert_eq!(
901 external_diagnostic,
902 CredentialDiagnostic::new(
903 ApiKeySource::ExternalAuthDeclared,
904 CredentialAvailability::NotProbed,
905 )
906 );
907 assert!(!doctor_has_credentials_or_local_runtime(&external_config));
908 }
909
910 #[test]
911 fn credential_diagnostic_certifies_only_no_auth_and_local_runtime_without_keys() {
912 let mut providers = crate::config::ProvidersConfig::default();
913 providers.openrouter.auth_mode = Some("none".to_string());
914 let no_auth = Config {
915 provider: Some("openrouter".to_string()),
916 providers: Some(providers),
917 ..Config::default()
918 };
919 assert_eq!(
920 resolve_credential_diagnostic(&no_auth),
921 CredentialDiagnostic::new(ApiKeySource::NoAuth, CredentialAvailability::NotRequired,)
922 );
923 assert!(doctor_has_credentials_or_local_runtime(&no_auth));
924
925 let local = Config {
926 provider: Some("ollama".to_string()),
927 ..Config::default()
928 };
929 assert_eq!(
930 resolve_credential_diagnostic(&local),
931 CredentialDiagnostic::new(
932 ApiKeySource::LocalRuntime,
933 CredentialAvailability::NotRequired,
934 )
935 );
936 assert!(doctor_has_credentials_or_local_runtime(&local));
937 }
938 #[test]
939 fn test_bare_stdio_command_is_structurally_valid_without_resolution() {
940 #[cfg(unix)]
941 let command = "sh";
942 #[cfg(windows)]
943 let command = "cmd";
944 let server = make_server(Some(command), &["serve", "--mcp"], None);
945 match doctor_check_mcp_server(&server) {
946 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("command omitted")),
947 other => panic!("Expected structural Ok, got {other:?}"),
948 }
949 }
950
950 lines RUST