返回 CodeWhale
tests.rs
根目录 / crates / cli / src / cloud / tests.rs
1 use std::collections::VecDeque;
2 use std::sync::{Arc, Mutex};
3
4 use clap::Parser;
5 use codewhale_secrets::account::{
6 ACCOUNT_SESSION_SCHEMA_VERSION, AccountSession as AuthSession,
7 account_auth_slot as cloud_auth_slot,
8 };
9 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10 use serde_json::json;
11
12 use super::*;
13 use crate::{Cli, Commands};
14
15 struct FakeTransport {
16 responses: Mutex<VecDeque<CloudResponse>>,
17 requests: Mutex<Vec<CloudRequest>>,
18 }
19
20 impl FakeTransport {
21 fn new(responses: Vec<CloudResponse>) -> Self {
22 Self {
23 responses: Mutex::new(responses.into()),
24 requests: Mutex::new(Vec::new()),
25 }
26 }
27
28 fn requests(&self) -> std::sync::MutexGuard<'_, Vec<CloudRequest>> {
29 self.requests.lock().unwrap()
30 }
31 }
32
33 impl CloudTransport for FakeTransport {
34 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
35 self.requests.lock().unwrap().push(request);
36 self.responses
37 .lock()
38 .unwrap()
39 .pop_front()
40 .ok_or_else(|| anyhow!("fake transport exhausted"))
41 }
42 }
43
44 fn response(status: u16, body: serde_json::Value) -> CloudResponse {
45 CloudResponse {
46 status,
47 body: serde_json::to_vec(&body).unwrap(),
48 retry_after: None,
49 }
50 }
51
52 fn response_retry_after(status: u16, body: serde_json::Value, seconds: u64) -> CloudResponse {
53 CloudResponse {
54 status,
55 body: serde_json::to_vec(&body).unwrap(),
56 retry_after: Some(seconds),
57 }
58 }
59
60 fn account(id: &str) -> serde_json::Value {
61 json!({
62 "user": {
63 "id": id,
64 "displayName": "Hunter",
65 "email": "hunter@example.test",
66 "plan": "free",
67 "modelKeys": {}
68 }
69 })
70 }
71
72 /// A stand-in for `GET /api/model-providers`.
73 ///
74 /// Deliberately includes ids the retired hardcoded enum never knew
75 /// (`modelstudio-coding-plan`) so a test failure means the CLI went back to a
76 /// compiled provider list.
77 fn catalog() -> serde_json::Value {
78 json!({
79 "providers": [
80 {
81 "id": "openai",
82 "label": "OpenAI",
83 "runtimeProvider": "openai",
84 "availability": "account_key",
85 "connectionAvailable": true
86 },
87 {
88 "id": "anthropic",
89 "label": "Anthropic",
90 "runtimeProvider": "anthropic",
91 "availability": "account_key",
92 "connectionAvailable": true
93 },
94 {
95 "id": "xiaomi",
96 "label": "Xiaomi MiMo",
97 "runtimeProvider": "xiaomi-mimo",
98 "availability": "account_key",
99 "connectionAvailable": true
100 },
101 {
102 "id": "modelstudio-coding-plan",
103 "label": "Alibaba Model Studio Coding Plan",
104 "runtimeProvider": "modelstudio-coding-plan",
105 "availability": "account_key",
106 "connectionAvailable": true
107 }
108 ]
109 })
110 }
111
112 fn auth(access: &str, refresh: &str, account_id: &str) -> AuthBundle {
113 AuthBundle {
114 token_type: "Bearer".to_string(),
115 access_token: access.to_string(),
116 refresh_token: refresh.to_string(),
117 session: Some(AuthSession {
118 id: "session-1".to_string(),
119 provider: "github".to_string(),
120 expires_at: String::new(),
121 refresh_expires_at: String::new(),
122 ..AuthSession::default()
123 }),
124 user: Some(CloudUser {
125 id: account_id.to_string(),
126 display_name: "Hunter".to_string(),
127 email: "hunter@example.test".to_string(),
128 ..CloudUser::default()
129 }),
130 }
131 }
132
133 fn auth_json(access: &str, refresh: &str, account_id: &str) -> serde_json::Value {
134 serde_json::to_value(auth(access, refresh, account_id)).unwrap()
135 }
136
137 fn test_secrets() -> (Secrets, Arc<InMemoryKeyringStore>) {
138 let store = Arc::new(InMemoryKeyringStore::new());
139 (Secrets::new(store.clone()), store)
140 }
141
142 fn test_config() -> (tempfile::TempDir, ConfigStore) {
143 let temp = tempfile::tempdir().unwrap();
144 let path = temp.path().join("config.toml");
145 let config = ConfigStore::load(Some(path)).unwrap();
146 (temp, config)
147 }
148
149 fn command(argv: &[&str]) -> CloudCommand {
150 let cli = Cli::try_parse_from(argv).unwrap();
151 let Some(Commands::Account(args)) = cli.command else {
152 panic!("expected account command");
153 };
154 args.command
155 }
156
157 #[test]
158 fn parses_cloud_command_matrix_and_rejects_inline_keys() {
159 assert!(matches!(
160 command(&["codewhale", "account", "status"]),
161 CloudCommand::Status
162 ));
163 assert!(matches!(
164 command(&["codewhale", "cloud", "login", "--no-open"]),
165 CloudCommand::Login(CloudLoginArgs { no_open: true, .. })
166 ));
167 assert!(matches!(
168 command(&[
169 "codewhale",
170 "cloud",
171 "keys",
172 "set",
173 "xiaomi-mimo",
174 "--from-local"
175 ]),
176 CloudCommand::Keys(CloudKeysArgs {
177 command: CloudKeysCommand::Set(CloudKeySetArgs {
178 from_local: true,
179 ..
180 })
181 })
182 ));
183 // Provider ids are open strings validated against the account's catalog,
184 // not a compiled enum: clap must not reject an id this CLI never heard of.
185 assert!(matches!(
186 command(&[
187 "codewhale",
188 "cloud",
189 "keys",
190 "set",
191 "modelstudio-coding-plan"
192 ]),
193 CloudCommand::Keys(CloudKeysArgs {
194 command: CloudKeysCommand::Set(CloudKeySetArgs { .. })
195 })
196 ));
197 assert!(
198 Cli::try_parse_from([
199 "codewhale",
200 "cloud",
201 "keys",
202 "set",
203 "openai",
204 "sk-unsafe-inline"
205 ])
206 .is_err()
207 );
208 assert!(
209 Cli::try_parse_from([
210 "codewhale",
211 "cloud",
212 "keys",
213 "set",
214 "openai",
215 "--from-local",
216 "--api-key-stdin"
217 ])
218 .is_err()
219 );
220 assert!(reject_inline_api_key(None).is_ok());
221 let error = reject_inline_api_key(Some("sk-never-render")).unwrap_err();
222 assert!(error.to_string().contains("--api-key-stdin"));
223 assert!(!error.to_string().contains("sk-never-render"));
224 }
225
226 #[test]
227 fn api_base_requires_https_or_literal_loopback_http() {
228 assert_eq!(
229 validate_api_base("https://api.codewhale.net/")
230 .unwrap()
231 .display,
232 "https://api.codewhale.net"
233 );
234 assert!(validate_api_base("http://127.0.0.1:8787").is_ok());
235 assert!(validate_api_base("http://[::1]:8787").is_ok());
236 assert!(validate_api_base("http://api.codewhale.net").is_err());
237 assert!(validate_api_base("https://user:secret@example.test").is_err());
238 assert!(validate_api_base("https://example.test/prefix").is_err());
239 }
240
241 #[test]
242 fn verification_urls_are_pinned_to_the_app_or_loopback() {
243 const CODE: &str = "ABCD-EFGH-JKLM";
244 const API: &str = "https://api.codewhale.net";
245 assert!(
246 validate_verification_url("https://app.codewhale.net/cli/authorize", API, CODE, false,)
247 .is_ok()
248 );
249 assert!(
250 validate_verification_url(
251 "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
252 API,
253 CODE,
254 true,
255 )
256 .is_ok()
257 );
258 for unsafe_url in [
259 "https://attacker.example/cli/authorize",
260 "https://user@app.codewhale.net/cli/authorize",
261 "https://app.codewhale.net/cli/authorize#continue",
262 "https://app.codewhale.net/cli/authorize/extra",
263 "https://app.codewhale.net/cli/other/../authorize",
264 "https://app.codewhale.net/cli/%61uthorize",
265 "https://app.codewhale.net/cli/authorize?next=https%3A%2F%2Fattacker.example",
266 "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM&next=evil",
267 ] {
268 assert!(
269 validate_verification_url(unsafe_url, API, CODE, unsafe_url.contains("user_code"))
270 .is_err(),
271 "accepted unsafe URL: {unsafe_url}"
272 );
273 }
274 assert!(
275 validate_verification_url(
276 "http://localhost:3000/cli/authorize?user_code=ABCD-EFGH-JKLM",
277 "http://127.0.0.1:8787",
278 CODE,
279 true,
280 )
281 .is_ok()
282 );
283 assert!(
284 validate_verification_url(
285 "https://staging-app.example/cli/authorize",
286 "https://staging-api.example",
287 CODE,
288 false,
289 )
290 .is_err()
291 );
292 }
293
294 #[test]
295 fn user_codes_and_key_inputs_match_the_server_contract() {
296 assert!(validate_user_code("ABCD-EFGH-JKLM").is_ok());
297 for invalid in [
298 "CW-1234",
299 "ABCI-EFGH-JKLM",
300 "ABCO-EFGH-JKLM",
301 "ABC1-EFGH-JKLM",
302 "abcd-EFGH-JKLM",
303 "ABCD_EFGH_JKLM",
304 ] {
305 assert!(validate_user_code(invalid).is_err(), "accepted {invalid}");
306 }
307
308 assert!(validate_device_code("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").is_ok());
309 for invalid in [
310 "too-short",
311 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
312 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA!",
313 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
314 ] {
315 assert!(validate_device_code(invalid).is_err(), "accepted {invalid}");
316 }
317
318 assert!(validate_api_key("1234567").is_err());
319 assert!(validate_api_key("12345678").is_ok());
320 assert!(validate_api_key(&"x".repeat(4096)).is_ok());
321 assert!(validate_api_key(&"x".repeat(4097)).is_err());
322 assert!(validate_api_key(&"é".repeat(4)).is_ok());
323 assert!(validate_api_key("1234567\n8").is_err());
324 assert_eq!(
325 parse_key_input(format!("{}\n", "x".repeat(4096)).into_bytes()).unwrap(),
326 "x".repeat(4096)
327 );
328 assert!(parse_key_input(vec![b'x'; MAX_API_KEY_STDIN_BYTES as usize + 1]).is_err());
329 assert_eq!(
330 validate_label(" Codewhale\tCLI ").unwrap(),
331 "Codewhale CLI"
332 );
333 assert!(validate_label(&"x".repeat(80)).is_ok());
334 assert!(validate_label(&"x".repeat(81)).is_err());
335 }
336
337 #[test]
338 fn device_flow_handles_pending_then_authorized_without_printing_tokens() {
339 for (no_open, browser_opens) in [(false, true), (false, false), (true, false)] {
340 let (temp, config) = test_config();
341 let _keep_temp = temp;
342 let (secrets, _) = test_secrets();
343 let transport = FakeTransport::new(vec![
344 response(
345 200,
346 json!({
347 "deviceCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
348 "userCode": "ABCD-EFGH-JKLM",
349 "verificationUri": "https://app.codewhale.net/cli/authorize",
350 "verificationUriComplete": "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
351 "expiresIn": 600,
352 "interval": 1
353 }),
354 ),
355 response(202, json!({ "status": "authorization_pending" })),
356 response(
357 200,
358 auth_json("access-never-print", "refresh-never-print", "acct-123"),
359 ),
360 response(200, account("acct-123")),
361 ]);
362 let mut output = Vec::new();
363 let mut key_reader = |_| bail!("key reader should not be called");
364 let mut opened = Vec::new();
365 let mut opener = |url: String| {
366 opened.push(url);
367 browser_opens
368 };
369 let mut sleeper = |_| {};
370 run_with(
371 command(if no_open {
372 &["codewhale", "cloud", "login", "--no-open"]
373 } else {
374 &["codewhale", "cloud", "login"]
375 }),
376 "work",
377 "https://api.codewhale.net",
378 &config,
379 &secrets,
380 &secrets,
381 &machine::MachineKeyEnv::default(),
382 &transport,
383 &mut output,
384 &mut key_reader,
385 &mut opener,
386 &mut sleeper,
387 )
388 .unwrap();
389
390 let output = String::from_utf8(output).unwrap();
391 assert_eq!(
392 output.lines().find(|line| line.starts_with("Open: ")),
393 Some("Open: https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM")
394 );
395 assert!(output.contains("ABCD-EFGH-JKLM"));
396 assert!(output.contains("Account ID: acct-123"));
397 assert!(output.contains("Profile: work"));
398 // No-brand invariant: login signs in the account; the internal
399 // cloud-agent credential is never taught here.
400 assert!(!output.to_lowercase().contains("daytona"), "{output}");
401 assert!(!output.contains("set-slot"), "{output}");
402 assert!(!output.contains("access-never-print"));
403 assert!(!output.contains("refresh-never-print"));
404 assert!(!output.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
405 if no_open {
406 assert!(opened.is_empty());
407 } else {
408 assert_eq!(
409 opened,
410 ["https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM"]
411 );
412 }
413 assert_eq!(
414 output.contains("Browser could not be opened; use the URL and code above."),
415 !no_open && !browser_opens
416 );
417 let requests = transport.requests();
418 assert_eq!(requests[0].path, "/api/cli/device/start");
419 assert_eq!(requests[1].path, "/api/cli/device/token");
420 assert_eq!(requests[2].path, "/api/cli/device/token");
421 assert_eq!(requests[3].path, "/api/me");
422 }
423 }
424
425 #[test]
426 fn cloud_sessions_are_isolated_by_profile_and_api_origin() {
427 let (secrets, _) = test_secrets();
428 let transport = FakeTransport::new(vec![]);
429 let default = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
430 let work = CloudClient::new(&transport, &secrets, "work", "https://api.codewhale.net");
431 let local = CloudClient::new(&transport, &secrets, "default", "http://127.0.0.1:8787");
432 default
433 .save_auth(auth("a-default", "r-default", "acct-default"))
434 .unwrap();
435 work.save_auth(auth("a-work", "r-work", "acct-work"))
436 .unwrap();
437 local
438 .save_auth(auth("a-local", "r-local", "acct-local"))
439 .unwrap();
440
441 assert_eq!(
442 default
443 .load_auth()
444 .unwrap()
445 .unwrap()
446 .bundle
447 .user
448 .unwrap()
449 .id,
450 "acct-default"
451 );
452 assert_eq!(
453 work.load_auth().unwrap().unwrap().bundle.user.unwrap().id,
454 "acct-work"
455 );
456 assert_eq!(
457 local.load_auth().unwrap().unwrap().bundle.user.unwrap().id,
458 "acct-local"
459 );
460 }
461
462 #[test]
463 fn status_refreshes_once_on_unauthorized_and_never_displays_tokens() {
464 let (temp, config) = test_config();
465 let _keep_temp = temp;
466 let (secrets, _) = test_secrets();
467 let transport = FakeTransport::new(vec![
468 response(401, json!({ "code": "access_token_expired" })),
469 response(
470 200,
471 auth_json("access-new-secret", "refresh-new-secret", "acct-refresh"),
472 ),
473 response(200, account("acct-refresh")),
474 ]);
475 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
476 .save_auth(auth(
477 "access-old-secret",
478 "refresh-old-secret",
479 "acct-refresh",
480 ))
481 .unwrap();
482 let mut output = Vec::new();
483 let mut key_reader = |_| bail!("unused");
484 let mut opener = |_| true;
485 let mut sleeper = |_| {};
486 run_with(
487 CloudCommand::Status,
488 "default",
489 "https://api.codewhale.net",
490 &config,
491 &secrets,
492 &secrets,
493 &machine::MachineKeyEnv::default(),
494 &transport,
495 &mut output,
496 &mut key_reader,
497 &mut opener,
498 &mut sleeper,
499 )
500 .unwrap();
501 let output = String::from_utf8(output).unwrap();
502 assert!(output.contains("acct-refresh"));
503 for secret in [
504 "access-old-secret",
505 "refresh-old-secret",
506 "access-new-secret",
507 "refresh-new-secret",
508 ] {
509 assert!(!output.contains(secret));
510 }
511 let requests = transport.requests();
512 assert_eq!(requests[0].path, "/api/me");
513 assert_eq!(requests[1].path, "/api/auth/refresh");
514 assert_eq!(requests[2].path, "/api/me");
515 }
516
517 #[test]
518 fn account_pull_refuses_to_claim_unimplemented_local_import() {
519 let (temp, config) = test_config();
520 let config_path = config.path().to_path_buf();
521 let (secrets, _) = test_secrets();
522 let transport = FakeTransport::new(vec![]);
523 let mut output = Vec::new();
524 let mut key_reader = |_| bail!("unused");
525 let mut opener = |_| true;
526 let mut sleeper = |_| {};
527
528 let error = run_with(
529 command(&["codewhale", "account", "pull"]),
530 "default",
531 "https://api.codewhale.net",
532 &config,
533 &secrets,
534 &secrets,
535 &machine::MachineKeyEnv::default(),
536 &transport,
537 &mut output,
538 &mut key_reader,
539 &mut opener,
540 &mut sleeper,
541 )
542 .expect_err("non-dry-run pull must fail until settings import exists");
543
544 assert!(error.to_string().contains("import is not available"));
545 assert!(error.to_string().contains("local config was not changed"));
546 assert!(
547 output.is_empty(),
548 "a rejected pull must not print success text"
549 );
550 assert!(
551 transport.requests().is_empty(),
552 "a rejected pull needs no API call"
553 );
554 assert!(
555 !config_path.exists(),
556 "a rejected pull must not create config.toml"
557 );
558 drop(temp);
559 }
560
561 #[test]
562 fn account_pull_dry_run_is_truthful_and_read_only() {
563 let (temp, config) = test_config();
564 let config_path = config.path().to_path_buf();
565 let (secrets, _) = test_secrets();
566 let transport = FakeTransport::new(vec![response(200, account("acct-pull"))]);
567 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
568 .save_auth(auth("access-secret", "refresh-secret", "acct-pull"))
569 .unwrap();
570 let mut output = Vec::new();
571 let mut key_reader = |_| bail!("unused");
572 let mut opener = |_| true;
573 let mut sleeper = |_| {};
574
575 run_with(
576 command(&["codewhale", "account", "pull", "--dry-run"]),
577 "default",
578 "https://api.codewhale.net",
579 &config,
580 &secrets,
581 &secrets,
582 &machine::MachineKeyEnv::default(),
583 &transport,
584 &mut output,
585 &mut key_reader,
586 &mut opener,
587 &mut sleeper,
588 )
589 .unwrap();
590
591 let output = String::from_utf8(output).unwrap();
592 assert!(output.contains("Account settings (pull --dry-run):"));
593 assert!(output.contains("Account ID: acct-pull"));
594 assert!(output.contains("remote settings import is not available"));
595 assert!(output.contains("local config unchanged"));
596 assert!(!output.contains("Pulled account document"));
597 assert!(!output.contains("would hydrate"));
598 assert!(!output.contains("access-secret"));
599 assert!(!output.contains("refresh-secret"));
600 assert!(!config_path.exists(), "dry-run must not create config.toml");
601 let requests = transport.requests();
602 assert_eq!(requests.len(), 1);
603 assert!(requests[0].method == HttpMethod::Get);
604 assert_eq!(requests[0].path, "/api/me");
605 drop(temp);
606 }
607
608 #[test]
609 fn non_terminal_refresh_responses_preserve_the_local_session() {
610 for status in [403, 429, 500, 503] {
611 let (secrets, _) = test_secrets();
612 let transport = FakeTransport::new(vec![
613 response(401, json!({ "code": "access_token_expired" })),
614 response(status, json!({ "code": "temporarily_unavailable" })),
615 ]);
616 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
617 client
618 .save_auth(auth(
619 "access-old-secret",
620 "refresh-still-valid",
621 "acct-refresh",
622 ))
623 .unwrap();
624
625 let error = client
626 .me()
627 .err()
628 .expect("refresh response should fail the request")
629 .to_string();
630 assert!(error.contains(&format!("HTTP {status}")));
631 assert_eq!(
632 client
633 .load_auth()
634 .unwrap()
635 .expect("retryable refresh failure must preserve the session")
636 .bundle
637 .refresh_token,
638 "refresh-still-valid"
639 );
640 let requests = transport.requests();
641 assert_eq!(requests.len(), 2);
642 assert_eq!(requests[0].path, "/api/me");
643 assert_eq!(requests[1].path, "/api/auth/refresh");
644 }
645 }
646
647 /// Revoke is defined as idempotent by the control plane (a repeat returns the
648 /// identical `revokedAt`), so a rate-limited revoke may be replayed — and the
649 /// server's own `Retry-After` decides how long CI waits, not a guess.
650 #[test]
651 fn a_rate_limited_revoke_waits_the_server_named_interval_and_replays() {
652 let (secrets, _) = test_secrets();
653 let transport = FakeTransport::new(vec![
654 response_retry_after(
655 429,
656 json!({
657 "error": "rate_limited",
658 "message": "slow down",
659 "details": { "code": "rate_limited" }
660 }),
661 4,
662 ),
663 response(
664 200,
665 json!({ "ok": true, "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "revokedAt": "2026-02-02T00:00:00Z" } }),
666 ),
667 ]);
668 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
669 client
670 .save_auth(auth("access-secret", "refresh-secret", "acct-1"))
671 .unwrap();
672 let mut slept = Vec::new();
673 let mut sleeper = |duration: Duration| slept.push(duration);
674 let response = client
675 .execute_authenticated_with_retry(
676 HttpMethod::Delete,
677 "/api/account/api-keys/3f2a9c1e4b7d8a0f5c6e2b91",
678 None,
679 machine::Retry::Idempotent,
680 &mut sleeper,
681 )
682 .unwrap();
683 assert_eq!(response.status, 200);
684 assert_eq!(slept, vec![Duration::from_secs(4)]);
685 assert_eq!(transport.requests().len(), 2);
686 }
687
688 /// The one POST in this surface mints a secret shown exactly once. A replay
689 /// that actually succeeded server-side would leave a key the caller can never
690 /// revoke by id, so `Retry::Never` must mean never — even on a 429.
691 #[test]
692 fn a_rate_limited_create_is_never_replayed() {
693 let (secrets, _) = test_secrets();
694 let transport = FakeTransport::new(vec![response_retry_after(
695 429,
696 json!({
697 "error": "rate_limited",
698 "message": "slow down",
699 "details": { "code": "rate_limited" }
700 }),
701 4,
702 )]);
703 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
704 client
705 .save_auth(auth("access-secret", "refresh-secret", "acct-1"))
706 .unwrap();
707 let mut sleeper = |_: Duration| panic!("create must never sleep-and-retry");
708 let response = client
709 .execute_authenticated_with_retry(
710 HttpMethod::Post,
711 "/api/account/api-keys",
712 Some(b"{}".to_vec()),
713 machine::Retry::Never,
714 &mut sleeper,
715 )
716 .unwrap();
717 assert_eq!(response.status, 429);
718 assert_eq!(transport.requests().len(), 1);
719 }
720
721 #[test]
722 fn refresh_transport_failure_preserves_the_local_session() {
723 let (secrets, _) = test_secrets();
724 let transport = FakeTransport::new(vec![response(
725 401,
726 json!({ "code": "access_token_expired" }),
727 )]);
728 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
729 client
730 .save_auth(auth(
731 "access-old-secret",
732 "refresh-still-valid",
733 "acct-refresh",
734 ))
735 .unwrap();
736
737 let error = client
738 .me()
739 .err()
740 .expect("refresh transport should fail")
741 .to_string();
742 assert!(error.contains("fake transport exhausted"));
743 assert_eq!(
744 client
745 .load_auth()
746 .unwrap()
747 .expect("transport failure must preserve the session")
748 .bundle
749 .refresh_token,
750 "refresh-still-valid"
751 );
752 let requests = transport.requests();
753 assert_eq!(requests.len(), 2);
754 assert_eq!(requests[1].path, "/api/auth/refresh");
755 }
756
757 #[test]
758 fn terminal_refresh_auth_failures_clear_the_local_session() {
759 let (secrets, _) = test_secrets();
760 let transport = FakeTransport::new(vec![
761 response(401, json!({ "code": "access_token_expired" })),
762 response(401, json!({ "code": "invalid_refresh_token" })),
763 ]);
764 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
765 client
766 .save_auth(auth(
767 "access-old-secret",
768 "refresh-terminal-secret",
769 "acct-refresh",
770 ))
771 .unwrap();
772
773 let error = client
774 .me()
775 .err()
776 .expect("terminal refresh response should fail the request")
777 .to_string();
778 assert!(error.contains("session expired"));
779 assert!(
780 client.load_auth().unwrap().is_none(),
781 "HTTP 401 must clear the terminal session"
782 );
783 }
784
785 #[test]
786 fn set_list_and_remove_use_account_routes_without_secret_output() {
787 let (temp, config) = test_config();
788 let _keep_temp = temp;
789 let (secrets, _) = test_secrets();
790 let list_account = json!({
791 "user": {
792 "id": "acct-keys",
793 "displayName": "Hunter",
794 "email": "hunter@example.test",
795 "modelKeys": {
796 "openai": { "configured": true, "label": "Laptop", "updatedAt": "now" }
797 }
798 }
799 });
800 let transport = FakeTransport::new(vec![
801 // set: /api/me, catalog, PUT
802 response(200, account("acct-keys")),
803 response(200, catalog()),
804 response(200, json!({ "ok": true })),
805 // list: /api/me, catalog
806 response(200, list_account),
807 response(200, catalog()),
808 // remove: /api/me, catalog, DELETE
809 response(200, account("acct-keys")),
810 response(200, catalog()),
811 response(204, json!(null)),
812 ]);
813 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
814 .save_auth(auth("access-secret", "refresh-secret", "acct-keys"))
815 .unwrap();
816 let mut output = Vec::new();
817 let mut key_reader = |_| Ok("sk-provider-never-print".to_string());
818 let mut opener = |_| true;
819 let mut sleeper = |_| {};
820 for cmd in [
821 command(&[
822 "codewhale",
823 "cloud",
824 "keys",
825 "set",
826 "openai",
827 "--api-key-stdin",
828 "--label",
829 "Laptop",
830 ]),
831 command(&["codewhale", "cloud", "keys", "list"]),
832 command(&["codewhale", "cloud", "keys", "remove", "openai"]),
833 ] {
834 run_with(
835 cmd,
836 "default",
837 "https://api.codewhale.net",
838 &config,
839 &secrets,
840 &secrets,
841 &machine::MachineKeyEnv::default(),
842 &transport,
843 &mut output,
844 &mut key_reader,
845 &mut opener,
846 &mut sleeper,
847 )
848 .unwrap();
849 }
850 let output = String::from_utf8(output).unwrap();
851 assert!(output.contains("openai: set"));
852 // Every catalog provider is listed, including ids the retired enum lacked.
853 assert!(output.contains("modelstudio-coding-plan: not set"));
854 assert!(output.contains("Alibaba Model Studio Coding Plan"));
855 assert!(!output.contains("Laptop"));
856 assert!(output.contains("Codewhale account acct-keys"));
857 assert!(!output.contains("sk-provider-never-print"));
858 assert!(!output.contains("access-secret"));
859 assert!(!output.contains("refresh-secret"));
860
861 let requests = transport.requests();
862 let put = requests
863 .iter()
864 .find(|request| request.method == HttpMethod::Put)
865 .unwrap();
866 assert_eq!(put.path, "/api/model-keys/openai");
867 assert_eq!(
868 serde_json::from_slice::<serde_json::Value>(put.body.as_ref().unwrap()).unwrap(),
869 json!({ "key": "sk-provider-never-print", "label": "Laptop" })
870 );
871 assert!(requests.iter().any(|request| {
872 request.method == HttpMethod::Delete && request.path == "/api/model-keys/openai"
873 }));
874 }
875
876 #[test]
877 fn from_local_uses_config_without_printing_or_requiring_an_inline_key() {
878 let temp = tempfile::tempdir().unwrap();
879 let path = temp.path().join("config.toml");
880 let mut config = ConfigStore::load(Some(path)).unwrap();
881 config.config.providers.anthropic.api_key = Some("sk-local-upload-secret".to_string());
882 let (secrets, _) = test_secrets();
883 let transport = FakeTransport::new(vec![
884 response(200, account("acct-local")),
885 response(200, catalog()),
886 response(200, json!({ "ok": true })),
887 ]);
888 CloudClient::new(&transport, &secrets, "work", "https://api.codewhale.net")
889 .save_auth(auth("access", "refresh", "acct-local"))
890 .unwrap();
891 let mut output = Vec::new();
892 let mut key_reader = |_| bail!("from-local must not prompt");
893 let mut opener = |_| true;
894 let mut sleeper = |_| {};
895 run_with(
896 command(&[
897 "codewhale",
898 "cloud",
899 "keys",
900 "set",
901 "anthropic",
902 "--from-local",
903 ]),
904 "work",
905 "https://api.codewhale.net",
906 &config,
907 &secrets,
908 &secrets,
909 &machine::MachineKeyEnv::default(),
910 &transport,
911 &mut output,
912 &mut key_reader,
913 &mut opener,
914 &mut sleeper,
915 )
916 .unwrap();
917 let output = String::from_utf8(output).unwrap();
918 assert!(output.contains("acct-local"));
919 assert!(!output.contains("sk-local-upload-secret"));
920 let requests = transport.requests();
921 let put = requests
922 .iter()
923 .find(|request| request.method == HttpMethod::Put)
924 .unwrap();
925 assert!(String::from_utf8_lossy(put.body.as_ref().unwrap()).contains("sk-local-upload-secret"));
926 }
927
928 #[test]
929 fn catalog_ids_map_to_local_providers_through_the_catalog_not_a_compiled_table() {
930 // `xiaomi` is the control plane's route id; `xiaomi-mimo` is the runtime's.
931 // The catalog states that mapping, so `--from-local` must read it from the
932 // response rather than from a compiled slug table.
933 let temp = tempfile::tempdir().unwrap();
934 let path = temp.path().join("config.toml");
935 let mut config = ConfigStore::load(Some(path)).unwrap();
936 config.config.providers.xiaomi_mimo.api_key = Some("sk-mimo-local".to_string());
937 let (secrets, _) = test_secrets();
938 let transport = FakeTransport::new(vec![
939 response(200, account("acct-map")),
940 response(200, catalog()),
941 response(200, json!({ "ok": true })),
942 ]);
943 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
944 .save_auth(auth("access", "refresh", "acct-map"))
945 .unwrap();
946 let mut output = Vec::new();
947 let mut key_reader = |_| bail!("from-local must not prompt");
948 let mut opener = |_| true;
949 let mut sleeper = |_| {};
950 run_with(
951 command(&[
952 "codewhale",
953 "cloud",
954 "keys",
955 "set",
956 "xiaomi",
957 "--from-local",
958 ]),
959 "default",
960 "https://api.codewhale.net",
961 &config,
962 &secrets,
963 &secrets,
964 &machine::MachineKeyEnv::default(),
965 &transport,
966 &mut output,
967 &mut key_reader,
968 &mut opener,
969 &mut sleeper,
970 )
971 .unwrap();
972 let output = String::from_utf8(output).unwrap();
973 assert!(!output.contains("sk-mimo-local"), "{output}");
974 let requests = transport.requests();
975 let put = requests
976 .iter()
977 .find(|request| request.method == HttpMethod::Put)
978 .expect("a PUT to the catalog route id");
979 assert_eq!(put.path, "/api/model-keys/xiaomi");
980 assert!(String::from_utf8_lossy(put.body.as_ref().unwrap()).contains("sk-mimo-local"));
981 }
982
983 #[test]
984 fn an_id_outside_the_account_catalog_is_refused_and_names_what_is_available() {
985 let (temp, config) = test_config();
986 let _keep_temp = temp;
987 let (secrets, _) = test_secrets();
988 let transport = FakeTransport::new(vec![
989 response(200, account("acct-unknown")),
990 response(200, catalog()),
991 ]);
992 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
993 .save_auth(auth("access", "refresh", "acct-unknown"))
994 .unwrap();
995 let mut output = Vec::new();
996 let mut key_reader = |_| bail!("an unknown provider must not prompt for a key");
997 let mut opener = |_| true;
998 let mut sleeper = |_| {};
999 let error = run_with(
1000 command(&["codewhale", "cloud", "keys", "remove", "not-a-provider"]),
1001 "default",
1002 "https://api.codewhale.net",
1003 &config,
1004 &secrets,
1005 &secrets,
1006 &machine::MachineKeyEnv::default(),
1007 &transport,
1008 &mut output,
1009 &mut key_reader,
1010 &mut opener,
1011 &mut sleeper,
1012 )
1013 .expect_err("an id the account cannot connect must fail");
1014 let text = error.to_string();
1015 assert!(text.contains("modelstudio-coding-plan"), "{text}");
1016 assert!(
1017 !transport
1018 .requests()
1019 .iter()
1020 .any(|request| request.method == HttpMethod::Delete),
1021 "an unknown id must never reach a mutating route"
1022 );
1023 }
1024
1025 #[test]
1026 fn provider_ids_are_validated_before_they_can_reach_a_url_path() {
1027 validate_provider_id("modelstudio-coding-plan").unwrap();
1028 validate_provider_id(" deepseek ").unwrap();
1029 for bad in [
1030 "",
1031 "-leading",
1032 "Upper",
1033 "has_underscore",
1034 "../escape",
1035 "with/slash",
1036 &"a".repeat(65),
1037 ] {
1038 assert!(
1039 validate_provider_id(bad).is_err(),
1040 "{bad:?} must be refused"
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn from_local_uses_config_before_the_provider_secret_store() {
1047 let (temp, mut config) = test_config();
1048 let _keep_temp = temp;
1049 let (secrets, store) = test_secrets();
1050 store.set("openai", "sk-secret-store").unwrap();
1051
1052 assert_eq!(
1053 resolve_local_key(&config, &secrets, ProviderKind::Openai)
1054 .unwrap()
1055 .as_deref(),
1056 Some("sk-secret-store")
1057 );
1058 config.config.providers.openai.api_key = Some("sk-config-first".to_string());
1059 assert_eq!(
1060 resolve_local_key(&config, &secrets, ProviderKind::Openai)
1061 .unwrap()
1062 .as_deref(),
1063 Some("sk-config-first")
1064 );
1065 }
1066
1067 #[test]
1068 fn logout_recovers_from_a_corrupt_local_session_record() {
1069 let (temp, config) = test_config();
1070 let _keep_temp = temp;
1071 let (secrets, store) = test_secrets();
1072 let slot = cloud_auth_slot("default", "https://api.codewhale.net");
1073 store.set(&slot, "not-json-and-not-a-token").unwrap();
1074 let transport = FakeTransport::new(vec![]);
1075 let mut output = Vec::new();
1076 let mut key_reader = |_| bail!("unused");
1077 let mut opener = |_| true;
1078 let mut sleeper = |_| {};
1079 run_with(
1080 CloudCommand::Logout,
1081 "default",
1082 "https://api.codewhale.net",
1083 &config,
1084 &secrets,
1085 &secrets,
1086 &machine::MachineKeyEnv::default(),
1087 &transport,
1088 &mut output,
1089 &mut key_reader,
1090 &mut opener,
1091 &mut sleeper,
1092 )
1093 .unwrap();
1094 assert!(store.get(&slot).unwrap().is_none());
1095 assert!(
1096 !String::from_utf8(output)
1097 .unwrap()
1098 .contains("not-json-and-not-a-token")
1099 );
1100 }
1101
1102 #[test]
1103 fn logout_clears_obsolete_or_wrong_origin_session_records() {
1104 let canonical_api_base = "https://api.codewhale.net";
1105 for (case, schema_version, stored_api_base) in [
1106 (
1107 "obsolete schema",
1108 ACCOUNT_SESSION_SCHEMA_VERSION.saturating_add(1),
1109 canonical_api_base,
1110 ),
1111 (
1112 "wrong origin",
1113 ACCOUNT_SESSION_SCHEMA_VERSION,
1114 "https://other.codewhale.net",
1115 ),
1116 ] {
1117 let (secrets, store) = test_secrets();
1118 let slot = cloud_auth_slot("default", canonical_api_base);
1119 let raw = serde_json::to_string(&StoredCloudAuth {
1120 schema_version,
1121 api_base: stored_api_base.to_string(),
1122 bundle: auth("access-obsolete", "refresh-obsolete", "acct-obsolete"),
1123 })
1124 .unwrap();
1125 store.set(&slot, &raw).unwrap();
1126 let transport = FakeTransport::new(vec![]);
1127 let client = CloudClient::new(&transport, &secrets, "default", canonical_api_base);
1128
1129 assert!(
1130 client.load_auth().unwrap().is_none(),
1131 "{case} must continue to load as signed out"
1132 );
1133 assert!(!client.logout().unwrap());
1134 assert!(
1135 store.get(&slot).unwrap().is_none(),
1136 "logout must scrub the {case} record"
1137 );
1138 assert!(transport.requests().is_empty());
1139 }
1140 }
1141
1142 #[test]
1143 fn server_errors_never_echo_response_messages() {
1144 let error = response_error(&response(
1145 400,
1146 json!({
1147 "error": {
1148 "code": "invalid_api_key",
1149 "message": "The submitted key was sk-never-echo-this"
1150 }
1151 }),
1152 ))
1153 .to_string();
1154 assert!(error.contains("invalid_api_key"));
1155 assert!(!error.contains("sk-never-echo-this"));
1156 }
1157
1158 #[test]
1159 fn cloud_auth_slot_does_not_embed_profile_or_origin() {
1160 let slot = cloud_auth_slot("private-profile", "https://api.codewhale.net");
1161 assert!(!slot.contains("private-profile"));
1162 assert!(!slot.contains("api.codewhale.net"));
1163 assert_ne!(
1164 slot,
1165 cloud_auth_slot("other-profile", "https://api.codewhale.net")
1166 );
1167 }
1168
1169 #[test]
1170 fn fake_store_is_profile_safe() {
1171 let (_, store) = test_secrets();
1172 store.set("unrelated", "keep-me").unwrap();
1173 store.delete("missing").unwrap();
1174 assert_eq!(store.get("unrelated").unwrap().as_deref(), Some("keep-me"));
1175 }
1176
1177 #[test]
1178 fn account_login_timeout_fails_the_command() {
1179 // §2.3 / #5033 class: a timed-out device login printed the timeout yet the
1180 // process exited 0. Pin the contract at the run_with seam — the command
1181 // must return Err so run_cli maps it to ExitCode::FAILURE. Verified live
1182 // against a stub server: `error: Codewhale account login timed out` now
1183 // exits 1.
1184 let (temp, config) = test_config();
1185 let _keep_temp = temp;
1186 let (secrets, _) = test_secrets();
1187 // Device start succeeds once; every token poll stays pending forever.
1188 struct PendingLogin;
1189 impl CloudTransport for PendingLogin {
1190 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
1191 if request.path == "/api/cli/device/start" {
1192 return Ok(response(
1193 200,
1194 json!({
1195 "deviceCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1196 "userCode": "ABCD-EFGH-JKLM",
1197 "verificationUri": "https://app.codewhale.net/cli/authorize",
1198 "verificationUriComplete": "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
1199 "expiresIn": 600,
1200 "interval": 1
1201 }),
1202 ));
1203 }
1204 Ok(response(202, json!({ "status": "authorization_pending" })))
1205 }
1206 }
1207 let pending = PendingLogin;
1208 let mut output = Vec::new();
1209 let mut key_reader = |_| bail!("key reader should not be called");
1210 let mut opener = |_| true;
1211 // A real (short) sleep keeps the pending loop from busy-spinning while
1212 // still reaching the 1s client timeout quickly.
1213 let mut sleeper = |duration: std::time::Duration| {
1214 std::thread::sleep(duration.min(std::time::Duration::from_millis(50)))
1215 };
1216 let result = run_with(
1217 command(&[
1218 "codewhale",
1219 "cloud",
1220 "login",
1221 "--no-open",
1222 "--timeout-seconds",
1223 "1",
1224 ]),
1225 "default",
1226 "https://api.codewhale.net",
1227 &config,
1228 &secrets,
1229 &secrets,
1230 &machine::MachineKeyEnv::default(),
1231 &pending,
1232 &mut output,
1233 &mut key_reader,
1234 &mut opener,
1235 &mut sleeper,
1236 );
1237 let err = match result {
1238 Ok(()) => panic!("a timed-out login must return Err so the exit code is non-zero"),
1239 Err(err) => err,
1240 };
1241 assert!(
1242 err.to_string().contains("login timed out"),
1243 "timeout error text: {err}"
1244 );
1245 }
1246
1247 // ---------------------------------------------------------------------------
1248 // Machine-token command surface, end to end through `run_with`.
1249 // ---------------------------------------------------------------------------
1250
1251 const MACHINE_TOKEN: &str =
1252 "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-xQRST";
1253
1254 fn machine_env() -> machine::MachineKeyEnv {
1255 machine::MachineKeyEnv::from_raw(Some(MACHINE_TOKEN))
1256 }
1257
1258 /// Drive one `codewhale account …` invocation with a scripted transport.
1259 fn run_account(
1260 argv: &[&str],
1261 machine: &machine::MachineKeyEnv,
1262 secrets: &Secrets,
1263 transport: &FakeTransport,
1264 ) -> (Result<()>, String) {
1265 let (temp, config) = test_config();
1266 let _keep_temp = temp;
1267 let mut output = Vec::new();
1268 let mut key_reader = |_| bail!("key reader should not be called");
1269 let mut opener = |_| true;
1270 let mut sleeper = |_| {};
1271 let result = run_with(
1272 command(argv),
1273 "default",
1274 "https://api.codewhale.net",
1275 &config,
1276 secrets,
1277 secrets,
1278 machine,
1279 transport,
1280 &mut output,
1281 &mut key_reader,
1282 &mut opener,
1283 &mut sleeper,
1284 );
1285 (result, String::from_utf8(output).unwrap())
1286 }
1287
1288 #[test]
1289 fn whoami_with_a_machine_key_uses_the_key_route_and_never_the_session_route() {
1290 let (secrets, _) = test_secrets();
1291 let transport = FakeTransport::new(vec![response(
1292 200,
1293 json!({
1294 "account": { "id": "user_1", "displayName": "Hunter", "email": "h@example.test",
1295 "region": "us-west", "plan": "free" },
1296 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "github-actions",
1297 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
1298 "scopes": ["account:read", "agent:run"],
1299 "createdAt": "2026-01-01T00:00:00Z" },
1300 "agent": { "configured": true, "modelProvider": "deepseek" }
1301 }),
1302 )]);
1303 let (result, output) = run_account(
1304 &["codewhale", "account", "whoami"],
1305 &machine_env(),
1306 &secrets,
1307 &transport,
1308 );
1309 result.unwrap();
1310 let requests = transport.requests();
1311 assert_eq!(requests.len(), 1);
1312 assert_eq!(requests[0].path, "/api/account/api-key/whoami");
1313 // Exactly one credential on the wire, and it is the machine key.
1314 assert_eq!(requests[0].bearer.as_deref(), Some(MACHINE_TOKEN));
1315 assert!(output.contains("user_1"), "{output}");
1316 assert!(
1317 output.contains("cwc_key_3f2a9c1e4b7d8a0f5c6e2b91"),
1318 "{output}"
1319 );
1320 assert!(
1321 !output.contains(&MACHINE_TOKEN[32..]),
1322 "secret half leaked: {output}"
1323 );
1324 }
1325
1326 /// The load-bearing failure mode: a machine credential that fails must not
1327 /// quietly become a human one, or CI runs as the wrong identity.
1328 #[test]
1329 fn a_rejected_machine_key_never_falls_back_to_the_stored_session() {
1330 let (secrets, _) = test_secrets();
1331 let transport = FakeTransport::new(vec![response(
1332 401,
1333 json!({
1334 "error": "unauthorized",
1335 "message": "invalid key",
1336 "details": { "code": "api_key_invalid" }
1337 }),
1338 )]);
1339 // A perfectly good interactive session exists alongside the bad key.
1340 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1341 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1342 .unwrap();
1343 let (result, output) = run_account(
1344 &["codewhale", "account", "whoami"],
1345 &machine_env(),
1346 &secrets,
1347 &transport,
1348 );
1349 let err = result.expect_err("an invalid machine key must fail the command");
1350 assert_eq!(
1351 transport.requests().len(),
1352 1,
1353 "there must be no second attempt"
1354 );
1355 assert!(output.is_empty(), "nothing should be printed: {output}");
1356 let machine_error = err
1357 .downcast_ref::<machine::MachineError>()
1358 .expect("the failure must carry a class");
1359 assert_eq!(machine_error.exit_code, machine::EXIT_AUTH);
1360 assert!(err.to_string().contains("is not valid"), "{err}");
1361 // The human session is untouched: a bad key is not a reason to log anyone out.
1362 assert!(
1363 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1364 .load_auth()
1365 .unwrap()
1366 .is_some()
1367 );
1368 }
1369
1370 #[test]
1371 fn managing_keys_with_only_a_machine_key_is_refused_before_anything_is_sent() {
1372 let (secrets, _) = test_secrets();
1373 for argv in [
1374 vec!["codewhale", "account", "api-keys", "list"],
1375 vec!["codewhale", "account", "api-keys", "create", "--name", "ci"],
1376 vec![
1377 "codewhale",
1378 "account",
1379 "api-keys",
1380 "revoke",
1381 "3f2a9c1e4b7d8a0f5c6e2b91",
1382 ],
1383 ] {
1384 let transport = FakeTransport::new(Vec::new());
1385 let (result, output) = run_account(&argv, &machine_env(), &secrets, &transport);
1386 let err = result.expect_err("a key cannot manage keys");
1387 assert!(
1388 transport.requests().is_empty(),
1389 "{argv:?} put the key on the wire"
1390 );
1391 assert!(output.is_empty(), "{argv:?}: {output}");
1392 assert!(
1393 err.to_string()
1394 .contains("Managing API keys needs an interactive login."),
1395 "{argv:?}: {err}"
1396 );
1397 }
1398 }
1399
1400 #[test]
1401 fn create_use_saves_the_key_only_in_the_local_codewhale_slot() {
1402 let (secrets, keyring) = test_secrets();
1403 let transport = FakeTransport::new(vec![response(
1404 201,
1405 json!({
1406 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "laptop",
1407 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
1408 "scopes": ["account:read", "agent:run", "models:infer"],
1409 "createdAt": "2026-01-01T00:00:00Z", "expiresAt": null },
1410 "secret": MACHINE_TOKEN
1411 }),
1412 )]);
1413 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1414 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1415 .unwrap();
1416 let (result, output) = run_account(
1417 &[
1418 "codewhale",
1419 "account",
1420 "api-keys",
1421 "create",
1422 "--name",
1423 "laptop",
1424 "--scope",
1425 "models:infer",
1426 "--use",
1427 ],
1428 &machine::MachineKeyEnv::default(),
1429 &secrets,
1430 &transport,
1431 );
1432 result.unwrap();
1433 // The secret is still printed exactly once, and the local save is stated.
1434 assert_eq!(output.matches(MACHINE_TOKEN).count(), 1, "{output}");
1435 assert!(
1436 output.contains("local `codewhale` provider credential"),
1437 "{output}"
1438 );
1439 assert_eq!(
1440 keyring.get("codewhale").unwrap().as_deref(),
1441 Some(MACHINE_TOKEN),
1442 "--use must write the codewhale provider slot"
1443 );
1444 // Only one request: --use is a local write, never an upload.
1445 let requests = transport.requests();
1446 assert_eq!(requests.len(), 1);
1447 let body: serde_json::Value =
1448 serde_json::from_slice(requests[0].body.as_ref().unwrap()).unwrap();
1449 assert_eq!(body["scopes"], json!(["models:infer"]), "{body}");
1450 }
1451
1452 #[test]
1453 fn creating_a_key_prints_the_secret_exactly_once_and_saves_it_nowhere() {
1454 let (secrets, keyring) = test_secrets();
1455 let transport = FakeTransport::new(vec![response(
1456 201,
1457 json!({
1458 "apiKey": { "id": "3f2a9c1e4b7d8a0f5c6e2b91", "name": "github-actions",
1459 "displayPrefix": "cwc_key_3f2a9c1e4b7d8a0f5c6e2b91",
1460 "scopes": ["account:read", "agent:run"],
1461 "createdAt": "2026-01-01T00:00:00Z", "expiresAt": null },
1462 "secret": MACHINE_TOKEN
1463 }),
1464 )]);
1465 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1466 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1467 .unwrap();
1468 let (result, output) = run_account(
1469 &[
1470 "codewhale",
1471 "account",
1472 "api-keys",
1473 "create",
1474 "--name",
1475 "github-actions",
1476 "--expires-in-days",
1477 "90",
1478 ],
1479 &machine::MachineKeyEnv::default(),
1480 &secrets,
1481 &transport,
1482 );
1483 result.unwrap();
1484 assert_eq!(output.matches(MACHINE_TOKEN).count(), 1, "{output}");
1485 assert!(
1486 output.contains("ONLY TIME YOU WILL SEE THIS SECRET"),
1487 "{output}"
1488 );
1489
1490 let requests = transport.requests();
1491 assert_eq!(requests.len(), 1);
1492 assert_eq!(requests[0].path, "/api/account/api-keys");
1493 let body: serde_json::Value =
1494 serde_json::from_slice(requests[0].body.as_ref().unwrap()).unwrap();
1495 assert_eq!(body["name"], "github-actions");
1496 assert_eq!(body["expiresInDays"], 90);
1497 // Scopes omitted means every scope, stated explicitly so a key carries
1498 // exactly what this CLI's help promised.
1499 assert_eq!(
1500 body["scopes"],
1501 json!(["account:read", "agent:run", "models:infer"]),
1502 "{body}"
1503 );
1504
1505 // The plaintext exists in one response and nowhere else, ever: creating a
1506 // key must not write it into the session record on its way past.
1507 let stored = keyring
1508 .get(&cloud_auth_slot("default", "https://api.codewhale.net"))
1509 .unwrap()
1510 .expect("the session record is still there");
1511 assert!(
1512 !stored.contains(MACHINE_TOKEN),
1513 "the secret reached storage"
1514 );
1515 }
1516
1517 #[test]
1518 fn a_bad_key_name_is_rejected_locally_without_a_round_trip() {
1519 let (secrets, _) = test_secrets();
1520 let transport = FakeTransport::new(Vec::new());
1521 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1522 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1523 .unwrap();
1524 let (result, _) = run_account(
1525 &[
1526 "codewhale",
1527 "account",
1528 "api-keys",
1529 "create",
1530 "--name",
1531 "bad*name",
1532 ],
1533 &machine::MachineKeyEnv::default(),
1534 &secrets,
1535 &transport,
1536 );
1537 let err = result.expect_err("`*` is outside the server's name pattern");
1538 assert!(transport.requests().is_empty());
1539 assert!(
1540 err.to_string().contains("only letters, digits, spaces"),
1541 "{err}"
1542 );
1543 }
1544
1545 #[test]
1546 fn the_agent_precondition_surfaces_the_409_with_its_own_exit_class() {
1547 let (secrets, _) = test_secrets();
1548 let transport = FakeTransport::new(vec![response(
1549 409,
1550 json!({
1551 "error": "conflict",
1552 "message": "no agent model",
1553 "details": { "code": "account_agent_model_unconfigured" }
1554 }),
1555 )]);
1556 let (result, _) = run_account(
1557 &["codewhale", "account", "agent"],
1558 &machine_env(),
1559 &secrets,
1560 &transport,
1561 );
1562 let err = result.expect_err("machine work needs a model");
1563 let machine_error = err.downcast_ref::<machine::MachineError>().unwrap();
1564 // A configuration problem, not a credential problem — and CI must be able
1565 // to tell them apart from the exit code alone.
1566 assert_eq!(machine_error.exit_code, machine::EXIT_AGENT_UNCONFIGURED);
1567 assert_ne!(machine_error.exit_code, machine::EXIT_AUTH);
1568 assert_eq!(transport.requests().len(), 1, "409 must not be retried");
1569 assert!(
1570 err.to_string().contains("codewhale account keys set"),
1571 "{err}"
1572 );
1573 }
1574
1575 #[test]
1576 fn the_agent_command_has_no_session_fallback() {
1577 let (secrets, _) = test_secrets();
1578 let transport = FakeTransport::new(Vec::new());
1579 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1580 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1581 .unwrap();
1582 let (result, _) = run_account(
1583 &["codewhale", "account", "agent"],
1584 &machine::MachineKeyEnv::default(),
1585 &secrets,
1586 &transport,
1587 );
1588 let err = result.expect_err("the agent route is machine-key-only");
1589 assert!(transport.requests().is_empty());
1590 assert!(err.to_string().contains("CODEWHALE_API_KEY"), "{err}");
1591 }
1592
1593 #[test]
1594 fn whoami_without_a_machine_key_still_reports_the_interactive_session() {
1595 let (secrets, _) = test_secrets();
1596 let transport = FakeTransport::new(vec![response(200, account("acct-human"))]);
1597 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
1598 .save_auth(auth("access-secret", "refresh-secret", "acct-human"))
1599 .unwrap();
1600 let (result, output) = run_account(
1601 &["codewhale", "account", "whoami"],
1602 &machine::MachineKeyEnv::default(),
1603 &secrets,
1604 &transport,
1605 );
1606 result.unwrap();
1607 assert_eq!(transport.requests()[0].path, "/api/me");
1608 assert!(output.contains("acct-human"), "{output}");
1609 }
1610
1611 #[test]
1612 fn the_machine_token_surface_is_a_different_noun_from_the_provider_vault() {
1613 // `account keys` is the BYOK provider vault; `account api-keys` is the
1614 // machine tokens. Merging them would let one typo revoke the wrong thing.
1615 assert!(matches!(
1616 command(&["codewhale", "account", "keys", "list"]),
1617 CloudCommand::Keys(_)
1618 ));
1619 assert!(matches!(
1620 command(&["codewhale", "account", "api-keys", "list"]),
1621 CloudCommand::ApiKeys(_)
1622 ));
1623 }
1624
1625 struct ConcurrentSessionTransport {
1626 inner: FakeTransport,
1627 trigger: &'static str,
1628 hold_writes: bool,
1629 writes: std::sync::mpsc::Sender<()>,
1630 done: Mutex<std::sync::mpsc::Receiver<()>>,
1631 }
1632 impl CloudTransport for ConcurrentSessionTransport {
1633 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
1634 if request.path == self.trigger {
1635 self.writes.send(()).unwrap();
1636 if self.hold_writes {
1637 assert!(
1638 matches!(
1639 self.done
1640 .lock()
1641 .unwrap()
1642 .recv_timeout(Duration::from_millis(100)),
1643 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
1644 ),
1645 "account writer bypassed lifecycle transaction"
1646 );
1647 } else {
1648 self.done
1649 .lock()
1650 .unwrap()
1651 .recv_timeout(Duration::from_secs(5))
1652 .unwrap();
1653 }
1654 }
1655 self.inner.execute(request)
1656 }
1657 }
1658
1659 #[test]
1660 fn delayed_account_responses_never_replace_or_clear_a_new_sign_in() {
1661 for (operation, status) in [
1662 ("refresh", 200),
1663 ("refresh", 401),
1664 ("logout", 200),
1665 ("me", 200),
1666 ] {
1667 let (secrets, _) = test_secrets();
1668 let owner = AccountSessionStore::new(secrets.clone(), Some("default"), DEFAULT_API_BASE);
1669 owner
1670 .save(auth("old-access", "old-refresh", "old-account"))
1671 .unwrap();
1672 let (write_tx, write_rx) = std::sync::mpsc::channel();
1673 let (done_tx, done_rx) = std::sync::mpsc::channel();
1674 let writer = std::thread::spawn(move || {
1675 write_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1676 owner
1677 .save(auth("new-access", "new-refresh", "new-account"))
1678 .unwrap();
1679 done_tx.send(()).unwrap();
1680 });
1681 let (trigger, responses) = match operation {
1682 "refresh" => (
1683 "/api/auth/refresh",
1684 vec![
1685 response(401, json!({})),
1686 response(
1687 status,
1688 auth_json("rotated-access", "rotated-refresh", "old-account"),
1689 ),
1690 ],
1691 ),
1692 "logout" => ("/api/auth/logout", vec![response(status, json!({}))]),
1693 "me" => ("/api/me", vec![response(status, account("old-account"))]),
1694 _ => unreachable!(),
1695 };
1696 let transport = ConcurrentSessionTransport {
1697 inner: FakeTransport::new(responses),
1698 trigger,
1699 hold_writes: operation != "me",
1700 writes: write_tx,
1701 done: Mutex::new(done_rx),
1702 };
1703 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1704 if operation == "logout" {
1705 assert!(client.logout().unwrap());
1706 } else {
1707 assert!(client.me().is_err());
1708 }
1709 writer.join().unwrap();
1710 let current = client.load_auth().unwrap().unwrap();
1711 assert_eq!(current.bundle.access_token, "new-access");
1712 assert_eq!(current.bundle.refresh_token, "new-refresh");
1713 assert_eq!(current.bundle.user.unwrap().id, "new-account");
1714 assert_eq!(
1715 transport.inner.requests().len(),
1716 if operation == "refresh" {
1717 if status == 200 { 3 } else { 2 }
1718 } else {
1719 1
1720 }
1721 );
1722 }
1723 }
1724
1725 #[test]
1726 fn renewed_credentials_survive_retry_transport_failure() {
1727 let (secrets, _) = test_secrets();
1728 let transport = FakeTransport::new(vec![
1729 response(401, json!({})),
1730 response(
1731 200,
1732 auth_json("renewed-access", "renewed-refresh", "account"),
1733 ),
1734 ]);
1735 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1736 client
1737 .save_auth(auth("old-access", "old-refresh", "account"))
1738 .unwrap();
1739 assert!(client.me().is_err());
1740 let current = client.load_auth().unwrap().unwrap();
1741 assert_eq!(current.bundle.access_token, "renewed-access");
1742 assert_eq!(current.bundle.refresh_token, "renewed-refresh");
1743 }
1744
1745 #[test]
1746 fn concurrent_clients_spend_refresh_token_only_once() {
1747 use std::sync::{
1748 Barrier,
1749 atomic::{AtomicUsize, Ordering},
1750 };
1751 struct Transport {
1752 first_reads: AtomicUsize,
1753 refreshes: AtomicUsize,
1754 barrier: Barrier,
1755 }
1756 impl CloudTransport for Transport {
1757 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
1758 if request.path == "/api/auth/refresh" {
1759 self.refreshes.fetch_add(1, Ordering::SeqCst);
1760 return Ok(response(
1761 200,
1762 auth_json("new-access", "new-refresh", "account"),
1763 ));
1764 }
1765 if self.first_reads.fetch_add(1, Ordering::SeqCst) < 2 {
1766 self.barrier.wait();
1767 Ok(response(401, json!({})))
1768 } else {
1769 Ok(response(200, account("account")))
1770 }
1771 }
1772 }
1773 let dir = tempfile::tempdir().unwrap();
1774 let secrets = Secrets::new(Arc::new(codewhale_secrets::FileKeyringStore::new(
1775 dir.path().join("secrets.json"),
1776 )));
1777 let transport = Transport {
1778 first_reads: AtomicUsize::new(0),
1779 refreshes: AtomicUsize::new(0),
1780 barrier: Barrier::new(2),
1781 };
1782 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1783 client
1784 .save_auth(auth("old-access", "old-refresh", "account"))
1785 .unwrap();
1786 let successes = std::thread::scope(|scope| {
1787 let a = scope
1788 .spawn(|| CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE).me());
1789 let b = scope
1790 .spawn(|| CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE).me());
1791 [a.join().unwrap(), b.join().unwrap()]
1792 .into_iter()
1793 .filter(Result::is_ok)
1794 .count()
1795 });
1796 assert_eq!(successes, 1);
1797 assert_eq!(transport.refreshes.load(Ordering::SeqCst), 1);
1798 assert_eq!(
1799 client.load_auth().unwrap().unwrap().bundle.refresh_token,
1800 "new-refresh"
1801 );
1802 }
1803 #[test]
1804 fn logout_preserves_custody_until_server_confirms_revocation_or_dead_session() {
1805 for status in [
1806 None,
1807 Some(429),
1808 Some(500),
1809 Some(503),
1810 Some(200),
1811 Some(204),
1812 Some(401),
1813 Some(403),
1814 ] {
1815 let (secrets, _) = test_secrets();
1816 let responses = status
1817 .map(|code| vec![response(code, json!({}))])
1818 .unwrap_or_default();
1819 let transport = FakeTransport::new(responses);
1820 let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
1821 client
1822 .save_auth(auth("access-revoke", "refresh-revoke", "account"))
1823 .unwrap();
1824 let result = client.logout();
1825 if status.is_some_and(|s| (200..300).contains(&s) || matches!(s, 401 | 403)) {
1826 assert!(result.is_ok());
1827 assert!(client.load_auth().unwrap().is_none());
1828 } else {
1829 assert!(result.is_err());
1830 assert_eq!(
1831 client.load_auth().unwrap().unwrap().bundle.refresh_token,
1832 "refresh-revoke"
1833 );
1834 }
1835 }
1836 }
1837
1837 lines RUST