返回 CodeWhale
tests.rs
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
417 lines RUST