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