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