返回 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::ProviderKind> = doctor_api_key_providers().collect();
9 assert!(!rows.contains(&crate::config::ProviderKind::Antigravity));
10 assert!(rows.contains(&crate::config::ProviderKind::Deepseek));
11 assert!(rows.contains(&crate::config::ProviderKind::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.deepseek_table_base_url(),
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 for row in codewhale_config::descriptors::provider_compatibility() {
288 if row.kind == crate::config::ProviderKind::Antigravity {
289 continue;
290 }
291 let provider = row.id;
292 let config = Config {
293 provider: Some(provider.to_string()),
294 ..Config::default()
295 };
296 let report = doctor_provider_model_report_json(&config);
297 for field in ["credential_url", "credential_docs_url"] {
298 let Some(value) = report["auth"][field].as_str() else {
299 continue;
300 };
301 assert_eq!(
302 value,
303 crate::doctor::structural_url_authority(value),
304 "{provider:?} {field} must be authority-only"
305 );
306 assert!(
307 value
308 .chars()
309 .all(|character| !matches!(character, '?' | '#' | '@')),
310 "{provider:?} {field} must omit credential-bearing URL components"
311 );
312 }
313 }
314 }
315 #[test]
316 fn doctor_provider_url_reports_omit_secret_capable_components() {
317 let sentinels = [
318 "PROVIDER-URL-USER-SENTINEL",
319 "PROVIDER-URL-PASSWORD-SENTINEL",
320 "PROVIDER-URL-PATH-SENTINEL",
321 "PROVIDER-URL-QUERY-KEY-SENTINEL",
322 "PROVIDER-URL-QUERY-VALUE-SENTINEL",
323 "PROVIDER-URL-FRAGMENT-SENTINEL",
324 ];
325 let base_url = format!(
326 "https://{}:{}@provider.example.invalid:9443/{}/v1?{}={}#{}",
327 sentinels[0], sentinels[1], sentinels[2], sentinels[3], sentinels[4], sentinels[5]
328 );
329 let config = Config {
330 ..Config::default()
331 }
332 .with_legacy_root(None, Some(base_url));
333 let target = doctor_api_target(&config);
334 let human = format!(
335 "base_url: {}",
336 crate::doctor::structural_url_authority(&target.base_url)
337 );
338 let json = serde_json::json!({
339 "base_url": crate::doctor::structural_url_authority(&target.base_url),
340 "route": doctor_route_report(&config),
341 })
342 .to_string();
343
344 assert_eq!(human, "base_url: https://provider.example.invalid:9443");
345 for sentinel in sentinels {
346 assert!(!human.contains(sentinel));
347 assert!(!json.contains(sentinel));
348 }
349 }
350 #[test]
351 fn doctor_startup_skips_workspace_dotenv_loading() {
352 use std::cell::Cell;
353
354 for args in [
355 vec!["codewhale", "doctor"],
356 vec!["codewhale", "doctor", "--json"],
357 vec!["codewhale", "doctor", "--context-json"],
358 vec!["codewhale", "doctor", "--probe-mcp"],
359 vec!["codewhale", "doctor", "--check-updates"],
360 ] {
361 let phase = Cell::new(0);
362 let (_cli, command) = prepare_cli_startup(
363 parse_cli(&args),
364 || {
365 assert_eq!(phase.get(), 0);
366 phase.set(1);
367 },
368 || phase.set(2),
369 );
370
371 assert_eq!(phase.get(), 1, "doctor must not load workspace dotenv");
372 assert!(matches!(command, Some(Commands::Doctor(_))));
373 }
374 }
375 #[test]
376 fn explicit_doctor_api_probes_may_load_workspace_dotenv_credentials() {
377 use std::cell::Cell;
378
379 for args in [
380 ["codewhale", "doctor", "--probe-api"],
381 ["codewhale", "doctor", "--probe-local"],
382 ] {
383 let phase = Cell::new(0);
384 let (_cli, command) = prepare_cli_startup(
385 parse_cli(&args),
386 || phase.set(1),
387 || {
388 assert_eq!(phase.get(), 1);
389 phase.set(2);
390 },
391 );
392
393 assert_eq!(
394 phase.get(),
395 2,
396 "explicit API probes may resolve dotenv credentials"
397 );
398 assert!(matches!(command, Some(Commands::Doctor(_))));
399 }
400 }
401 #[test]
402 fn hosted_provider_does_not_probe_without_explicit_opt_in() {
403 assert!(!doctor_should_probe_api(
404 crate::config::ProviderKind::Deepseek,
405 "https://api.deepseek.com/beta",
406 crate::doctor::DoctorProbeRequest::default(),
407 ));
408 assert!(doctor_should_probe_api(
409 crate::config::ProviderKind::Deepseek,
410 "https://api.deepseek.com/beta",
411 crate::doctor::DoctorProbeRequest {
412 probe_api: true,
413 ..crate::doctor::DoctorProbeRequest::default()
414 },
415 ));
416 }
417 #[test]
418 fn resolve_api_key_source_does_not_clone_openai_codex_env_tokens() {
419 let _guard = crate::test_support::lock_test_env();
420 let sentinel = "OPENAI-CODEX-ACCESS-TOKEN-SENTINEL";
421 let _token = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_ACCESS_TOKEN", sentinel);
422 let _legacy_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
423 let config = Config {
424 provider: Some("openai-codex".to_string()),
425 ..Config::default()
426 };
427
428 let source = resolve_api_key_source(&config);
429 let report = doctor_provider_model_report_json(&config).to_string();
430
431 assert_eq!(source, ApiKeySource::OAuth);
432 assert_eq!(doctor_api_key_source_label(source), "oauth_unprobed");
433 assert!(!report.contains(sentinel));
434 }
435 #[test]
436 fn resolve_api_key_source_does_not_inspect_dispatcher_source_or_value() {
437 let _guard = crate::test_support::lock_test_env();
438 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
439 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
440 unsafe {
441 std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
442 std::env::set_var("DEEPSEEK_API_KEY_SOURCE", "keyring");
443 }
444 let cfg = Config::default();
445 let source = resolve_api_key_source(&cfg);
446 match prev {
447 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
448 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
449 }
450 match prev_source {
451 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
452 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
453 }
454 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
455 }
456 #[test]
457 fn resolve_api_key_source_does_not_inspect_provider_env_values() {
458 let _guard = crate::test_support::lock_test_env();
459 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
460 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
461 unsafe {
462 std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
463 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
464 }
465 let cfg = Config::default();
466 let source = resolve_api_key_source(&cfg);
467 match prev {
468 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
469 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
470 }
471 match prev_source {
472 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
473 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
474 }
475 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
476 }
477 #[test]
478 fn resolve_api_key_source_does_not_open_standalone_secret_store() {
479 let _lock = crate::test_support::lock_test_env();
480 let temp = TempDir::new().expect("temp home");
481 let codewhale_home = temp.path().join("codewhale-home");
482 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
483 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
484 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
485 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
486 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
487 let secret_path = codewhale_home.join("secrets").join("secrets.json");
488 std::fs::create_dir_all(secret_path.parent().unwrap()).expect("secret fixture dir");
489 let sentinel = "not-json:doctor-must-not-read-this-secret";
490 std::fs::write(&secret_path, sentinel).expect("secret fixture");
491
492 assert_eq!(
493 resolve_credential_diagnostic(&Config::default()),
494 CredentialDiagnostic::new(
495 ApiKeySource::SecretStoreUnprobed,
496 CredentialAvailability::NotProbed,
497 )
498 );
499 assert_eq!(std::fs::read_to_string(secret_path).unwrap(), sentinel);
500 }
501 #[test]
502 fn resolve_api_key_source_does_not_probe_system_keyring() {
503 let _lock = crate::test_support::lock_test_env();
504 let temp = TempDir::new().expect("temp home");
505 let _home = crate::test_support::EnvVarGuard::set(
506 "CODEWHALE_HOME",
507 temp.path().join("codewhale-home").as_os_str(),
508 );
509 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "system");
510 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
511 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
512
513 assert_eq!(
514 resolve_credential_diagnostic(&Config::default()),
515 CredentialDiagnostic::new(
516 ApiKeySource::SecretStoreUnprobed,
517 CredentialAvailability::NotProbed,
518 )
519 );
520 }
521
522 #[test]
523 fn credential_diagnostic_distinguishes_literal_from_empty_config_keys() {
524 let literal = Config {
525 ..Config::default()
526 }
527 .with_legacy_root(Some("TEST-LITERAL-CONFIG-KEY".to_string()), None);
528 assert_eq!(
529 resolve_credential_diagnostic(&literal),
530 CredentialDiagnostic::new(
531 ApiKeySource::ConfigDeclared,
532 CredentialAvailability::Present,
533 )
534 );
535 assert!(doctor_has_credentials_or_local_runtime(&literal));
536
537 let mut providers = crate::config::ProvidersConfig::default();
538 providers.openai.api_key = Some(" ".to_string());
539 let empty = Config {
540 provider: Some("openai".to_string()),
541 providers: Some(providers),
542 ..Config::default()
543 };
544 assert_eq!(
545 resolve_credential_diagnostic(&empty),
546 CredentialDiagnostic::new(
547 ApiKeySource::SecretStoreUnprobed,
548 CredentialAvailability::NotProbed,
549 )
550 );
551 assert!(!doctor_has_credentials_or_local_runtime(&empty));
552
553 let empty_root = Config {
554 ..Config::default()
555 }
556 .with_legacy_root(Some(String::new()), None);
557 assert_eq!(
558 resolve_credential_diagnostic(&empty_root).source,
559 ApiKeySource::SecretStoreUnprobed
560 );
561 assert!(!doctor_has_credentials_or_local_runtime(&empty_root));
562 }
563
564 #[test]
565 fn credential_diagnostic_treats_sentinel_as_unprobed_store_not_config() {
566 let _lock = crate::test_support::lock_test_env();
567 let temp = TempDir::new().expect("temp home");
568 let codewhale_home = temp.path().join("codewhale-home");
569 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
570 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
571 let config = Config {
572 ..Config::default()
573 }
574 .with_legacy_root(Some(crate::config::API_KEYRING_SENTINEL.to_string()), None);
575
576 assert_eq!(
577 resolve_credential_diagnostic(&config),
578 CredentialDiagnostic::new(
579 ApiKeySource::SecretStoreUnprobed,
580 CredentialAvailability::NotProbed,
581 )
582 );
583 assert!(!doctor_has_credentials_or_local_runtime(&config));
584 assert!(!codewhale_home.join("secrets/secrets.json").exists());
585
586 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
587 let mut providers = crate::config::ProvidersConfig::default();
588 providers.openai.api_key = Some(sentinel.to_string());
589 let official = Config {
590 provider: Some("openai".to_string()),
591 providers: Some(providers),
592 ..Config::default()
593 };
594 assert_eq!(
595 resolve_credential_diagnostic(&official),
596 CredentialDiagnostic::new(
597 ApiKeySource::SecretStoreUnprobed,
598 CredentialAvailability::NotProbed,
599 )
600 );
601 }
602 }
603
604 #[test]
605 fn unavailable_sentinel_routes_stay_distinct_from_unknown_and_unprobed() {
606 let _lock = crate::test_support::lock_test_env();
607 let temp = TempDir::new().expect("temp home");
608 let workspace = temp.path().join("workspace");
609 std::fs::create_dir_all(&workspace).expect("workspace");
610 let _home =
611 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path().join("codewhale-home"));
612
613 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
614 let mut custom = std::collections::HashMap::new();
615 custom.insert(
616 "sentinel-route".to_string(),
617 crate::config::ProviderConfig {
618 kind: Some("openai-compatible".to_string()),
619 base_url: Some("https://gateway.example.test/v1".to_string()),
620 model: Some("test-model".to_string()),
621 api_key: Some(sentinel.to_string()),
622 ..Default::default()
623 },
624 );
625 let named_custom = Config {
626 provider: Some("sentinel-route".to_string()),
627 providers: Some(crate::config::ProvidersConfig {
628 custom,
629 ..Default::default()
630 }),
631 ..Config::default()
632 };
633 assert_eq!(
634 resolve_credential_diagnostic(&named_custom),
635 CredentialDiagnostic::new(
636 ApiKeySource::SecretStoreUnavailable,
637 CredentialAvailability::Unavailable,
638 )
639 );
640 assert!(!doctor_has_credentials_or_local_runtime(&named_custom));
641 let setup = doctor_setup_report_json(&named_custom, &workspace);
642 assert_eq!(setup["credential"]["source"], "secret_store_unavailable");
643 assert_eq!(setup["credential"]["availability"], "unavailable");
644 assert_eq!(setup["credential"]["ready"], false);
645 assert_eq!(
646 setup["provider_model"]["auth"]["availability"],
647 "unavailable"
648 );
649 assert_eq!(
650 setup["operate_fleet"]["provider"]["auth"]["availability"],
651 "unavailable"
652 );
653 assert_eq!(setup["operate_fleet"]["ready"], false);
654 assert_eq!(
655 doctor_route_report(&named_custom)["auth"]["availability"],
656 "unavailable"
657 );
658 }
659
660 let mut providers = crate::config::ProvidersConfig::default();
661 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
662 providers.openrouter.api_key = Some(crate::config::API_KEYRING_SENTINEL.to_string());
663 let custom_endpoint = Config {
664 provider: Some("openrouter".to_string()),
665 providers: Some(providers),
666 ..Config::default()
667 };
668 assert_eq!(
669 resolve_credential_diagnostic(&custom_endpoint),
670 CredentialDiagnostic::new(
671 ApiKeySource::SecretStoreUnavailable,
672 CredentialAvailability::Unavailable,
673 )
674 );
675
676 let mut custom = std::collections::HashMap::new();
677 custom.insert(
678 "empty-route".to_string(),
679 crate::config::ProviderConfig {
680 kind: Some("openai-compatible".to_string()),
681 base_url: Some("https://empty.example.test/v1".to_string()),
682 model: Some("test-model".to_string()),
683 api_key: Some(" ".to_string()),
684 ..Default::default()
685 },
686 );
687 let empty_custom = Config {
688 provider: Some("empty-route".to_string()),
689 providers: Some(crate::config::ProvidersConfig {
690 custom,
691 ..Default::default()
692 }),
693 ..Config::default()
694 };
695 assert_eq!(
696 resolve_credential_diagnostic(&empty_custom),
697 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
698 );
699 }
700
701 #[test]
702 fn sentinel_placeholders_never_become_attemptable_routes_or_metered_evidence() {
703 let _lock = crate::test_support::lock_test_env();
704 let temp = TempDir::new().expect("isolated credential home");
705 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
706 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
707 let _xai = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
708 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
709 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
710 let _mimo_mode = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_MODE");
711 let _mimo_base = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_BASE_URL");
712 let _mimo_plan = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
713 let _mimo_plan_alias = crate::test_support::EnvVarGuard::remove("MIMO_TOKEN_PLAN_API_KEY");
714 let _mimo_key = crate::test_support::EnvVarGuard::remove("XIAOMI_MIMO_API_KEY");
715 let _xiaomi_key = crate::test_support::EnvVarGuard::remove("XIAOMI_API_KEY");
716 let _mimo_key_alias = crate::test_support::EnvVarGuard::remove("MIMO_API_KEY");
717
718 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
719 let named_custom = Config {
720 provider: Some("sentinel-route".to_string()),
721 providers: Some(crate::config::ProvidersConfig {
722 custom: std::collections::HashMap::from([(
723 "sentinel-route".to_string(),
724 crate::config::ProviderConfig {
725 kind: Some("openai-compatible".to_string()),
726 base_url: Some("https://gateway.example.test/v1".to_string()),
727 model: Some("sentinel-model".to_string()),
728 api_key: Some(sentinel.to_string()),
729 ..Default::default()
730 },
731 )]),
732 ..Default::default()
733 }),
734 ..Default::default()
735 };
736 assert_eq!(
737 crate::provider_readiness::credential_state_for_provider(
738 &named_custom,
739 &(named_custom).test_identity_for_kind(crate::config::ProviderKind::Custom),
740 ),
741 crate::provider_readiness::CredentialState::MissingKey
742 );
743 let readiness = crate::provider_readiness::resolve_for_model(
744 &named_custom,
745 &(named_custom).test_identity_for_kind(crate::config::ProviderKind::Custom),
746 "sentinel-model",
747 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
748 );
749 assert_eq!(
750 readiness,
751 crate::provider_readiness::ResolvedProviderReadiness::MissingKey
752 );
753 assert!(!readiness.can_attempt());
754 assert!(
755 crate::model_inventory::ModelInventory::from_config(&named_custom)
756 .unwrap()
757 .candidates
758 .iter()
759 .all(|candidate| candidate.provider != crate::config::ProviderKind::Custom)
760 );
761
762 let xai = Config {
763 provider: Some("xai".to_string()),
764 providers: Some(crate::config::ProvidersConfig {
765 xai: crate::config::ProviderConfig {
766 api_key: Some(sentinel.to_string()),
767 ..Default::default()
768 },
769 ..Default::default()
770 }),
771 ..Default::default()
772 };
773 assert_eq!(
774 crate::provider_readiness::credential_state_for_provider(
775 &xai,
776 &(xai).test_identity_for_kind(crate::config::ProviderKind::Xai),
777 ),
778 crate::provider_readiness::CredentialState::MissingKey
779 );
780
781 let mut xiaomi_providers = crate::config::ProvidersConfig::default();
782 xiaomi_providers.xiaomi_mimo.api_key = Some(sentinel.to_string());
783 let xiaomi = Config {
784 providers: Some(xiaomi_providers),
785 ..Default::default()
786 };
787 assert_eq!(
788 crate::route_billing::for_route(
789 &xiaomi,
790 &(xiaomi).test_identity_for_kind(crate::config::ProviderKind::XiaomiMimo)
791 ),
792 crate::route_billing::BillingPresentation::Subscription("MiMo token plan")
793 );
794 }
795 }
796
797 #[test]
798 fn sentinel_diagnostic_yields_to_route_bound_env_or_auth_declaration() {
799 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
800 let mut providers = crate::config::ProvidersConfig::default();
801 providers.openai.api_key = Some(sentinel.to_string());
802 providers.openai.api_key_env = Some("OFFICIAL_SENTINEL_ROUTE_KEY".to_string());
803 let official = Config {
804 provider: Some("openai".to_string()),
805 providers: Some(providers),
806 ..Config::default()
807 };
808 assert_eq!(
809 resolve_credential_diagnostic(&official),
810 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed,)
811 );
812
813 let mut custom = std::collections::HashMap::new();
814 custom.insert(
815 "sentinel-route".to_string(),
816 crate::config::ProviderConfig {
817 kind: Some("openai-compatible".to_string()),
818 base_url: Some("https://gateway.example.test/v1".to_string()),
819 model: Some("test-model".to_string()),
820 api_key: Some(sentinel.to_string()),
821 api_key_env: Some("CUSTOM_SENTINEL_ROUTE_KEY".to_string()),
822 ..Default::default()
823 },
824 );
825 let named_custom = Config {
826 provider: Some("sentinel-route".to_string()),
827 providers: Some(crate::config::ProvidersConfig {
828 custom,
829 ..Default::default()
830 }),
831 ..Config::default()
832 };
833 assert_eq!(
834 resolve_credential_diagnostic(&named_custom),
835 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed,)
836 );
837
838 let mut external = std::collections::HashMap::new();
839 external.insert(
840 "external-sentinel-route".to_string(),
841 crate::config::ProviderConfig {
842 kind: Some("openai-compatible".to_string()),
843 base_url: Some("https://external.example.test/v1".to_string()),
844 model: Some("test-model".to_string()),
845 api_key: Some(sentinel.to_string()),
846 auth: Some(codewhale_config::ProviderAuthSourceToml {
847 source: codewhale_config::AuthSourceKind::Command,
848 command: vec!["MUST-NOT-RUN".to_string()],
849 timeout_ms: None,
850 secret_id: None,
851 }),
852 ..Default::default()
853 },
854 );
855 let named_external = Config {
856 provider: Some("external-sentinel-route".to_string()),
857 providers: Some(crate::config::ProvidersConfig {
858 custom: external,
859 ..Default::default()
860 }),
861 ..Config::default()
862 };
863 assert_eq!(
864 resolve_credential_diagnostic(&named_external),
865 CredentialDiagnostic::new(
866 ApiKeySource::ExternalAuthDeclared,
867 CredentialAvailability::NotProbed,
868 )
869 );
870 }
871 }
872
873 #[test]
874 fn credential_declarations_do_not_certify_setup_or_fleet_readiness() {
875 let _lock = crate::test_support::lock_test_env();
876 let temp = TempDir::new().expect("temp home");
877 let codewhale_home = temp.path().join("codewhale-home");
878 let workspace = temp.path().join("workspace");
879 std::fs::create_dir_all(&workspace).expect("workspace");
880 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
881 let _declared_value =
882 crate::test_support::EnvVarGuard::set("TEST_DECLARED_API_KEY", "MUST-NOT-BE-READ");
883
884 let mut providers = crate::config::ProvidersConfig::default();
885 providers.openai.api_key_env = Some("TEST_DECLARED_API_KEY".to_string());
886 let env_config = Config {
887 provider: Some("openai".to_string()),
888 providers: Some(providers),
889 ..Config::default()
890 };
891 let env_diagnostic = resolve_credential_diagnostic(&env_config);
892 assert_eq!(env_diagnostic.source, ApiKeySource::EnvDeclared);
893 assert_eq!(
894 env_diagnostic.availability,
895 CredentialAvailability::NotProbed
896 );
897 let env_setup = doctor_setup_report_json(&env_config, &workspace);
898 assert_eq!(env_setup["credential"]["ready"], false);
899 assert_eq!(
900 env_setup["operate_fleet"]["provider"]["auth"]["present_or_local"],
901 false
902 );
903 assert_eq!(env_setup["operate_fleet"]["ready"], false);
904 assert!(!env_setup.to_string().contains("MUST-NOT-BE-READ"));
905
906 let mut providers = crate::config::ProvidersConfig::default();
907 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
908 source: codewhale_config::AuthSourceKind::Command,
909 command: vec!["MUST-NOT-RUN".to_string()],
910 timeout_ms: None,
911 secret_id: None,
912 });
913 let external_config = Config {
914 provider: Some("openai".to_string()),
915 providers: Some(providers),
916 ..Config::default()
917 };
918 let external_diagnostic = resolve_credential_diagnostic(&external_config);
919 assert_eq!(
920 external_diagnostic,
921 CredentialDiagnostic::new(
922 ApiKeySource::ExternalAuthDeclared,
923 CredentialAvailability::NotProbed,
924 )
925 );
926 assert!(!doctor_has_credentials_or_local_runtime(&external_config));
927 }
928
929 #[test]
930 fn credential_diagnostic_certifies_only_no_auth_and_local_runtime_without_keys() {
931 let mut providers = crate::config::ProvidersConfig::default();
932 providers.openrouter.auth_mode = Some("none".to_string());
933 let no_auth = Config {
934 provider: Some("openrouter".to_string()),
935 providers: Some(providers),
936 ..Config::default()
937 };
938 assert_eq!(
939 resolve_credential_diagnostic(&no_auth),
940 CredentialDiagnostic::new(ApiKeySource::NoAuth, CredentialAvailability::NotRequired,)
941 );
942 assert!(doctor_has_credentials_or_local_runtime(&no_auth));
943
944 let local = Config {
945 provider: Some("ollama".to_string()),
946 ..Config::default()
947 };
948 assert_eq!(
949 resolve_credential_diagnostic(&local),
950 CredentialDiagnostic::new(
951 ApiKeySource::LocalRuntime,
952 CredentialAvailability::NotRequired,
953 )
954 );
955 assert!(doctor_has_credentials_or_local_runtime(&local));
956 }
957 #[test]
958 fn test_bare_stdio_command_is_structurally_valid_without_resolution() {
959 #[cfg(unix)]
960 let command = "sh";
961 #[cfg(windows)]
962 let command = "cmd";
963 let server = make_server(Some(command), &["serve", "--mcp"], None);
964 match doctor_check_mcp_server(&server) {
965 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("command omitted")),
966 other => panic!("Expected structural Ok, got {other:?}"),
967 }
968 }
969
970 #[test]
971 fn receipts_cli_parses_id_last_turn_and_format() {
972 use clap::Parser as _;
973 let cli = crate::Cli::try_parse_from(["codewhale", "receipts", "--last", "--format", "json"])
974 .expect("parse --last");
975 assert!(matches!(
976 cli.command,
977 Some(crate::Commands::Receipts {
978 last: true,
979 id: None,
980 format: crate::receipts::ReceiptFormat::Json,
981 ..
982 })
983 ));
984 let cli = crate::Cli::try_parse_from(["codewhale", "receipt", "thr_1", "--turn", "turn_2"])
985 .expect("parse alias");
986 assert!(matches!(
987 cli.command,
988 Some(crate::Commands::Receipts { ref id, ref turn, format: crate::receipts::ReceiptFormat::Md, .. })
989 if id.as_deref() == Some("thr_1") && turn.as_deref() == Some("turn_2")
990 ));
991 assert!(
992 crate::Cli::try_parse_from(["codewhale", "receipts", "abc", "--last"]).is_err(),
993 "an id and --last conflict"
994 );
995 }
996
996 lines RUST