| 1 | use std::io::{self, Write}; |
| 2 | |
| 3 | use clap::Parser; |
| 4 | |
| 5 | use super::*; |
| 6 | use crate::{AuthArgs, AuthCommand, Cli, Commands, no_keyring_secrets}; |
| 7 | |
| 8 | const SENTINEL: &str = "cw-test-secret-never-log-7b30"; |
| 9 | const AMBIENT_SENTINEL: &str = "cw-test-ambient-openrouter-never-log-94cd"; |
| 10 | |
| 11 | fn without_ambient_credentials() -> Vec<crate::tests::ScopedEnvVar> { |
| 12 | let mut names = ProviderKind::ALL |
| 13 | .into_iter() |
| 14 | .flat_map(crate::provider_env_vars) |
| 15 | .copied() |
| 16 | .collect::<Vec<_>>(); |
| 17 | names.extend([ |
| 18 | codewhale_config::CLI_API_KEY_ENV, |
| 19 | codewhale_config::CLI_API_KEY_SOURCE_ENV, |
| 20 | codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV, |
| 21 | ]); |
| 22 | names.sort_unstable(); |
| 23 | names.dedup(); |
| 24 | names |
| 25 | .into_iter() |
| 26 | .map(crate::tests::ScopedEnvVar::remove) |
| 27 | .collect() |
| 28 | } |
| 29 | |
| 30 | fn expect_credential_error(result: anyhow::Result<String>, context: &'static str) -> anyhow::Error { |
| 31 | match result { |
| 32 | Err(error) => error, |
| 33 | Ok(_) => panic!("{context}: unexpectedly resolved a credential"), |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | #[test] |
| 38 | fn command_parses_and_documents_the_pipe_only_boundary() { |
| 39 | let cli = Cli::try_parse_from([ |
| 40 | "codewhale", |
| 41 | "auth", |
| 42 | "print-api-key", |
| 43 | "--provider", |
| 44 | "openrouter", |
| 45 | ]) |
| 46 | .expect("parse command"); |
| 47 | assert!(matches!( |
| 48 | cli.command, |
| 49 | Some(Commands::Auth(AuthArgs { |
| 50 | command: AuthCommand::PrintApiKey { |
| 51 | provider: ProviderKind::Openrouter |
| 52 | } |
| 53 | })) |
| 54 | )); |
| 55 | |
| 56 | let help = Cli::try_parse_from(["codewhale", "auth", "print-api-key", "--help"]) |
| 57 | .expect_err("help exits") |
| 58 | .to_string(); |
| 59 | assert!(help.contains("runtime-effective API key"), "{help}"); |
| 60 | assert!(help.contains("refuses terminals"), "{help}"); |
| 61 | } |
| 62 | |
| 63 | #[test] |
| 64 | fn pipe_receives_exact_secret_and_one_newline() { |
| 65 | let mut output = Vec::new(); |
| 66 | handoff_secret_line(&mut output, false, || Ok(SENTINEL.to_string())).expect("handoff"); |
| 67 | assert_eq!(output, format!("{SENTINEL}\n").as_bytes()); |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn terminal_refusal_happens_before_credential_resolution() { |
| 72 | let resolved = std::cell::Cell::new(false); |
| 73 | let mut output = Vec::new(); |
| 74 | let error = handoff_secret_line(&mut output, true, || { |
| 75 | resolved.set(true); |
| 76 | Ok(SENTINEL.to_string()) |
| 77 | }) |
| 78 | .expect_err("terminal must be refused"); |
| 79 | assert!( |
| 80 | !resolved.get(), |
| 81 | "terminal refusal must not read a credential" |
| 82 | ); |
| 83 | assert!(output.is_empty()); |
| 84 | assert!(error.to_string().contains("refusing")); |
| 85 | assert!(!error.to_string().contains(SENTINEL)); |
| 86 | } |
| 87 | |
| 88 | struct BrokenPipe; |
| 89 | |
| 90 | impl Write for BrokenPipe { |
| 91 | fn write(&mut self, _buf: &[u8]) -> io::Result<usize> { |
| 92 | Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) |
| 93 | } |
| 94 | |
| 95 | fn flush(&mut self) -> io::Result<()> { |
| 96 | Ok(()) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | #[test] |
| 101 | fn closed_consumer_is_a_clean_pipe_settlement() { |
| 102 | handoff_secret_line(&mut BrokenPipe, false, || Ok(SENTINEL.to_string())) |
| 103 | .expect("broken pipe is settled"); |
| 104 | } |
| 105 | |
| 106 | #[derive(Default)] |
| 107 | struct PartialThenBrokenPipe { |
| 108 | output: Vec<u8>, |
| 109 | } |
| 110 | |
| 111 | impl Write for PartialThenBrokenPipe { |
| 112 | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { |
| 113 | if self.output.is_empty() { |
| 114 | let accepted = buf.len().min(5); |
| 115 | self.output.extend_from_slice(&buf[..accepted]); |
| 116 | return Ok(accepted); |
| 117 | } |
| 118 | Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed")) |
| 119 | } |
| 120 | |
| 121 | fn flush(&mut self) -> io::Result<()> { |
| 122 | Ok(()) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | #[test] |
| 127 | fn partial_then_closed_consumer_is_also_a_clean_settlement() { |
| 128 | let mut writer = PartialThenBrokenPipe::default(); |
| 129 | handoff_secret_line(&mut writer, false, || Ok(SENTINEL.to_string())) |
| 130 | .expect("partial broken pipe is settled"); |
| 131 | assert_eq!(writer.output, SENTINEL.as_bytes()[..5]); |
| 132 | } |
| 133 | |
| 134 | #[derive(Default)] |
| 135 | struct PartialThenOtherFailure { |
| 136 | output: Vec<u8>, |
| 137 | } |
| 138 | |
| 139 | impl Write for PartialThenOtherFailure { |
| 140 | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { |
| 141 | if self.output.is_empty() { |
| 142 | let accepted = buf.len().min(5); |
| 143 | self.output.extend_from_slice(&buf[..accepted]); |
| 144 | return Ok(accepted); |
| 145 | } |
| 146 | Err(io::Error::other("sentinel-shaped operating system detail")) |
| 147 | } |
| 148 | |
| 149 | fn flush(&mut self) -> io::Result<()> { |
| 150 | Ok(()) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn output_failure_never_formats_secret_or_os_detail() { |
| 156 | let mut writer = PartialThenOtherFailure::default(); |
| 157 | let error = handoff_secret_line(&mut writer, false, || Ok(SENTINEL.to_string())) |
| 158 | .expect_err("non-pipe error after partial output"); |
| 159 | let rendered = error.to_string(); |
| 160 | assert_eq!(writer.output, SENTINEL.as_bytes()[..5]); |
| 161 | assert!(!rendered.contains(SENTINEL), "{rendered}"); |
| 162 | assert!(!rendered.contains("sentinel-shaped"), "{rendered}"); |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn resolution_failure_is_redacted() { |
| 167 | let mut output = Vec::new(); |
| 168 | let error = handoff_secret_line(&mut output, false, || { |
| 169 | anyhow::bail!("provider failed near {SENTINEL}") |
| 170 | }) |
| 171 | .expect_err("resolution failure"); |
| 172 | assert_eq!(error.to_string(), "unavailable credential"); |
| 173 | assert!(output.is_empty()); |
| 174 | } |
| 175 | |
| 176 | #[test] |
| 177 | fn api_key_resolves_the_runtime_provider_slot_without_printing_it() { |
| 178 | let _env_lock = crate::tests::env_lock(); |
| 179 | let _ambient_credentials = without_ambient_credentials(); |
| 180 | let dir = tempfile::tempdir().expect("tempdir"); |
| 181 | let path = dir.path().join("config.toml"); |
| 182 | let mut store = ConfigStore::load(Some(path)).expect("store"); |
| 183 | store.config.providers.openrouter.api_key = Some("cw-test-handoff-openrouter-5ca1".to_string()); |
| 184 | let secrets = no_keyring_secrets(); |
| 185 | |
| 186 | let value = resolve_api_key( |
| 187 | &store, |
| 188 | &secrets, |
| 189 | ProviderKind::Openrouter, |
| 190 | &CliRuntimeOverrides::default(), |
| 191 | ) |
| 192 | .expect("resolved API key"); |
| 193 | assert!( |
| 194 | value == "cw-test-handoff-openrouter-5ca1", |
| 195 | "resolved an unexpected credential source" |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn api_key_uses_shared_secret_store_precedence() { |
| 201 | use std::sync::Arc; |
| 202 | |
| 203 | use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; |
| 204 | |
| 205 | let _env_lock = crate::tests::env_lock(); |
| 206 | let _ambient_credentials = without_ambient_credentials(); |
| 207 | let dir = tempfile::tempdir().expect("tempdir"); |
| 208 | let path = dir.path().join("config.toml"); |
| 209 | let store = ConfigStore::load(Some(path)).expect("store"); |
| 210 | let inner = Arc::new(InMemoryKeyringStore::new()); |
| 211 | inner |
| 212 | .set("deepseek", "cw-test-handoff-keyring-e19a") |
| 213 | .expect("seed keyring"); |
| 214 | let secrets = Secrets::new(inner); |
| 215 | |
| 216 | let value = resolve_api_key( |
| 217 | &store, |
| 218 | &secrets, |
| 219 | ProviderKind::Deepseek, |
| 220 | &CliRuntimeOverrides::default(), |
| 221 | ) |
| 222 | .expect("resolved API key"); |
| 223 | assert!( |
| 224 | value == "cw-test-handoff-keyring-e19a", |
| 225 | "resolved an unexpected credential source" |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | #[test] |
| 230 | fn api_key_rejects_missing_and_bearer_owned_routes() { |
| 231 | let _env_lock = crate::tests::env_lock(); |
| 232 | let _ambient_credentials = without_ambient_credentials(); |
| 233 | let dir = tempfile::tempdir().expect("tempdir"); |
| 234 | let path = dir.path().join("config.toml"); |
| 235 | let mut store = ConfigStore::load(Some(path)).expect("store"); |
| 236 | let secrets = no_keyring_secrets(); |
| 237 | |
| 238 | let missing = expect_credential_error( |
| 239 | resolve_api_key( |
| 240 | &store, |
| 241 | &secrets, |
| 242 | ProviderKind::Openrouter, |
| 243 | &CliRuntimeOverrides::default(), |
| 244 | ), |
| 245 | "missing API key", |
| 246 | ); |
| 247 | assert!(missing.to_string().contains("no runtime-effective API key")); |
| 248 | |
| 249 | store.config.providers.openai_codex.external_credentials = |
| 250 | Some(codewhale_config::ExternalCredentialConsentToml::read_only( |
| 251 | ProviderKind::OpenaiCodex, |
| 252 | codewhale_config::ExternalCredentialSource::CodexCli, |
| 253 | crate::openai_codex_auth_file_path(), |
| 254 | )); |
| 255 | let codex = expect_credential_error( |
| 256 | resolve_api_key( |
| 257 | &store, |
| 258 | &secrets, |
| 259 | ProviderKind::OpenaiCodex, |
| 260 | &CliRuntimeOverrides::default(), |
| 261 | ), |
| 262 | "Codex token is not an API key", |
| 263 | ); |
| 264 | assert!(codex.to_string().contains("bearer credentials")); |
| 265 | |
| 266 | store.config.provider = ProviderKind::Xai; |
| 267 | store.config.providers.xai.auth_mode = Some("oauth".to_string()); |
| 268 | store.config.providers.xai.oauth_credential_generation = |
| 269 | Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string()); |
| 270 | let xai = expect_credential_error( |
| 271 | resolve_api_key( |
| 272 | &store, |
| 273 | &secrets, |
| 274 | ProviderKind::Xai, |
| 275 | &CliRuntimeOverrides::default(), |
| 276 | ), |
| 277 | "owned OAuth token is not an API key", |
| 278 | ); |
| 279 | assert!(xai.to_string().contains("OAuth bearer credentials")); |
| 280 | |
| 281 | store.config.providers.xai.oauth_credential_generation = None; |
| 282 | store.config.providers.xai.api_key = Some("cw-test-xai-key-must-not-win-91a4".to_string()); |
| 283 | store.config.providers.xai.external_credentials = |
| 284 | Some(codewhale_config::ExternalCredentialConsentToml::read_only( |
| 285 | ProviderKind::Xai, |
| 286 | codewhale_config::ExternalCredentialSource::GrokCli, |
| 287 | crate::grok_auth_file_path(), |
| 288 | )); |
| 289 | let external_xai = expect_credential_error( |
| 290 | resolve_api_key( |
| 291 | &store, |
| 292 | &secrets, |
| 293 | ProviderKind::Xai, |
| 294 | &CliRuntimeOverrides::default(), |
| 295 | ), |
| 296 | "external xAI bearer is not an API key", |
| 297 | ); |
| 298 | assert!( |
| 299 | external_xai |
| 300 | .to_string() |
| 301 | .contains("OAuth bearer credentials") |
| 302 | ); |
| 303 | |
| 304 | store.config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); |
| 305 | let kimi = expect_credential_error( |
| 306 | resolve_api_key( |
| 307 | &store, |
| 308 | &secrets, |
| 309 | ProviderKind::Moonshot, |
| 310 | &CliRuntimeOverrides::default(), |
| 311 | ), |
| 312 | "imported bearer token is not an API key", |
| 313 | ); |
| 314 | assert!(kimi.to_string().contains("bearer credentials")); |
| 315 | } |
| 316 | |
| 317 | #[test] |
| 318 | fn missing_key_test_is_hermetic_against_ambient_openrouter_credentials() { |
| 319 | let _env_lock = crate::tests::env_lock(); |
| 320 | let _ambient = crate::tests::ScopedEnvVar::set("OPENROUTER_API_KEY", AMBIENT_SENTINEL); |
| 321 | let _ambient_credentials = without_ambient_credentials(); |
| 322 | let dir = tempfile::tempdir().expect("tempdir"); |
| 323 | let store = ConfigStore::load(Some(dir.path().join("config.toml"))).expect("store"); |
| 324 | let secrets = no_keyring_secrets(); |
| 325 | |
| 326 | let error = expect_credential_error( |
| 327 | resolve_api_key( |
| 328 | &store, |
| 329 | &secrets, |
| 330 | ProviderKind::Openrouter, |
| 331 | &CliRuntimeOverrides::default(), |
| 332 | ), |
| 333 | "ambient OpenRouter credential isolation", |
| 334 | ); |
| 335 | let rendered = error.to_string(); |
| 336 | assert!(rendered.contains("no runtime-effective API key")); |
| 337 | assert!(!rendered.contains(AMBIENT_SENTINEL)); |
| 338 | } |
| 339 | |
| 340 | #[test] |
| 341 | fn custom_endpoint_keys_stay_endpoint_bound_and_cli_overrides_win() { |
| 342 | let _env_lock = crate::tests::env_lock(); |
| 343 | let _ambient_credentials = without_ambient_credentials(); |
| 344 | let dir = tempfile::tempdir().expect("tempdir"); |
| 345 | let path = dir.path().join("config.toml"); |
| 346 | let mut store = ConfigStore::load(Some(path)).expect("store"); |
| 347 | let secrets = no_keyring_secrets(); |
| 348 | |
| 349 | store.config.providers.xai.api_key = Some("cw-test-official-xai-key-72be".to_string()); |
| 350 | let custom_cli_route = CliRuntimeOverrides { |
| 351 | base_url: Some("https://gateway.example.test/v1".to_string()), |
| 352 | ..CliRuntimeOverrides::default() |
| 353 | }; |
| 354 | let inherited = expect_credential_error( |
| 355 | resolve_api_key(&store, &secrets, ProviderKind::Xai, &custom_cli_route), |
| 356 | "an official route key must not cross onto a CLI custom endpoint", |
| 357 | ); |
| 358 | assert!( |
| 359 | inherited |
| 360 | .to_string() |
| 361 | .contains("no runtime-effective API key") |
| 362 | ); |
| 363 | |
| 364 | store.config.providers.xai.base_url = Some("https://bound-gateway.example.test/v1".to_string()); |
| 365 | store.config.providers.xai.api_key = Some("cw-test-bound-xai-key-e5f8".to_string()); |
| 366 | let bound = resolve_api_key( |
| 367 | &store, |
| 368 | &secrets, |
| 369 | ProviderKind::Xai, |
| 370 | &CliRuntimeOverrides::default(), |
| 371 | ) |
| 372 | .expect("a route-local config key stays bound to its custom endpoint"); |
| 373 | assert!( |
| 374 | bound == "cw-test-bound-xai-key-e5f8", |
| 375 | "resolved an unexpected credential source" |
| 376 | ); |
| 377 | |
| 378 | store.config.providers.xai.auth_mode = Some("oauth".to_string()); |
| 379 | store.config.providers.xai.external_credentials = |
| 380 | Some(codewhale_config::ExternalCredentialConsentToml::read_only( |
| 381 | ProviderKind::Xai, |
| 382 | codewhale_config::ExternalCredentialSource::GrokCli, |
| 383 | crate::grok_auth_file_path(), |
| 384 | )); |
| 385 | let explicit = resolve_api_key( |
| 386 | &store, |
| 387 | &secrets, |
| 388 | ProviderKind::Xai, |
| 389 | &CliRuntimeOverrides { |
| 390 | api_key: Some("cw-test-explicit-cli-key-d61c".to_string()), |
| 391 | base_url: Some("https://another-gateway.example.test/v1".to_string()), |
| 392 | ..CliRuntimeOverrides::default() |
| 393 | }, |
| 394 | ) |
| 395 | .expect("an explicit CLI key wins for the exact requested endpoint"); |
| 396 | assert!( |
| 397 | explicit == "cw-test-explicit-cli-key-d61c", |
| 398 | "resolved an unexpected credential source" |
| 399 | ); |
| 400 | |
| 401 | store.config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); |
| 402 | let kimi_override = resolve_api_key( |
| 403 | &store, |
| 404 | &secrets, |
| 405 | ProviderKind::Moonshot, |
| 406 | &CliRuntimeOverrides { |
| 407 | api_key: Some("cw-test-explicit-kimi-key-bf42".to_string()), |
| 408 | ..CliRuntimeOverrides::default() |
| 409 | }, |
| 410 | ) |
| 411 | .expect("an explicit API key overrides a persisted imported-bearer mode"); |
| 412 | assert!( |
| 413 | kimi_override == "cw-test-explicit-kimi-key-bf42", |
| 414 | "resolved an unexpected credential source" |
| 415 | ); |
| 416 | } |
| 417 |