返回 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, 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
413 lines RUST