返回 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 account_file_session_store_opted_in_value as file_session_store_opted_in_value,
9 };
10 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
11 use serde_json::json;
12
13 use super::*;
14 use crate::{Cli, Commands};
15
16 struct FakeTransport {
17 responses: Mutex<VecDeque<CloudResponse>>,
18 requests: Mutex<Vec<CloudRequest>>,
19 }
20
21 impl FakeTransport {
22 fn new(responses: Vec<CloudResponse>) -> Self {
23 Self {
24 responses: Mutex::new(responses.into()),
25 requests: Mutex::new(Vec::new()),
26 }
27 }
28
29 fn requests(&self) -> std::sync::MutexGuard<'_, Vec<CloudRequest>> {
30 self.requests.lock().unwrap()
31 }
32 }
33
34 impl CloudTransport for FakeTransport {
35 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
36 self.requests.lock().unwrap().push(request);
37 self.responses
38 .lock()
39 .unwrap()
40 .pop_front()
41 .ok_or_else(|| anyhow!("fake transport exhausted"))
42 }
43 }
44
45 fn response(status: u16, body: serde_json::Value) -> CloudResponse {
46 CloudResponse {
47 status,
48 body: serde_json::to_vec(&body).unwrap(),
49 }
50 }
51
52 fn account(id: &str) -> serde_json::Value {
53 json!({
54 "user": {
55 "id": id,
56 "displayName": "Hunter",
57 "email": "hunter@example.test",
58 "plan": "free",
59 "modelKeys": {}
60 }
61 })
62 }
63
64 fn auth(access: &str, refresh: &str, account_id: &str) -> AuthBundle {
65 AuthBundle {
66 token_type: "Bearer".to_string(),
67 access_token: access.to_string(),
68 refresh_token: refresh.to_string(),
69 session: Some(AuthSession {
70 id: "session-1".to_string(),
71 provider: "github".to_string(),
72 expires_at: String::new(),
73 refresh_expires_at: String::new(),
74 ..AuthSession::default()
75 }),
76 user: Some(CloudUser {
77 id: account_id.to_string(),
78 display_name: "Hunter".to_string(),
79 email: "hunter@example.test".to_string(),
80 ..CloudUser::default()
81 }),
82 }
83 }
84
85 fn auth_json(access: &str, refresh: &str, account_id: &str) -> serde_json::Value {
86 serde_json::to_value(auth(access, refresh, account_id)).unwrap()
87 }
88
89 fn test_secrets() -> (Secrets, Arc<InMemoryKeyringStore>) {
90 let store = Arc::new(InMemoryKeyringStore::new());
91 (Secrets::new(store.clone()), store)
92 }
93
94 fn test_config() -> (tempfile::TempDir, ConfigStore) {
95 let temp = tempfile::tempdir().unwrap();
96 let path = temp.path().join("config.toml");
97 let config = ConfigStore::load(Some(path)).unwrap();
98 (temp, config)
99 }
100
101 fn command(argv: &[&str]) -> CloudCommand {
102 let cli = Cli::try_parse_from(argv).unwrap();
103 let Some(Commands::Account(args)) = cli.command else {
104 panic!("expected account command");
105 };
106 args.command
107 }
108
109 #[test]
110 fn parses_cloud_command_matrix_and_rejects_inline_keys() {
111 assert!(matches!(
112 command(&["codewhale", "account", "status"]),
113 CloudCommand::Status
114 ));
115 assert!(matches!(
116 command(&["codewhale", "cloud", "login", "--no-open"]),
117 CloudCommand::Login(CloudLoginArgs { no_open: true, .. })
118 ));
119 assert!(matches!(
120 command(&[
121 "codewhale",
122 "cloud",
123 "keys",
124 "set",
125 "xiaomi-mimo",
126 "--from-local"
127 ]),
128 CloudCommand::Keys(CloudKeysArgs {
129 command: CloudKeysCommand::Set(CloudKeySetArgs {
130 provider: CloudProvider::Xiaomi,
131 from_local: true,
132 ..
133 })
134 })
135 ));
136 assert!(
137 Cli::try_parse_from([
138 "codewhale",
139 "cloud",
140 "keys",
141 "set",
142 "openai",
143 "sk-unsafe-inline"
144 ])
145 .is_err()
146 );
147 assert!(
148 Cli::try_parse_from([
149 "codewhale",
150 "cloud",
151 "keys",
152 "set",
153 "openai",
154 "--from-local",
155 "--api-key-stdin"
156 ])
157 .is_err()
158 );
159 assert!(reject_inline_api_key(None).is_ok());
160 let error = reject_inline_api_key(Some("sk-never-render")).unwrap_err();
161 assert!(error.to_string().contains("--api-key-stdin"));
162 assert!(!error.to_string().contains("sk-never-render"));
163 }
164
165 #[test]
166 fn api_base_requires_https_or_literal_loopback_http() {
167 assert_eq!(
168 validate_api_base("https://api.codewhale.net/")
169 .unwrap()
170 .display,
171 "https://api.codewhale.net"
172 );
173 assert!(validate_api_base("http://127.0.0.1:8787").is_ok());
174 assert!(validate_api_base("http://[::1]:8787").is_ok());
175 assert!(validate_api_base("http://api.codewhale.net").is_err());
176 assert!(validate_api_base("https://user:secret@example.test").is_err());
177 assert!(validate_api_base("https://example.test/prefix").is_err());
178 }
179
180 #[test]
181 fn verification_urls_are_pinned_to_the_app_or_loopback() {
182 const CODE: &str = "ABCD-EFGH-JKLM";
183 const API: &str = "https://api.codewhale.net";
184 assert!(
185 validate_verification_url("https://app.codewhale.net/cli/authorize", API, CODE, false,)
186 .is_ok()
187 );
188 assert!(
189 validate_verification_url(
190 "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
191 API,
192 CODE,
193 true,
194 )
195 .is_ok()
196 );
197 for unsafe_url in [
198 "https://attacker.example/cli/authorize",
199 "https://user@app.codewhale.net/cli/authorize",
200 "https://app.codewhale.net/cli/authorize#continue",
201 "https://app.codewhale.net/cli/authorize/extra",
202 "https://app.codewhale.net/cli/other/../authorize",
203 "https://app.codewhale.net/cli/%61uthorize",
204 "https://app.codewhale.net/cli/authorize?next=https%3A%2F%2Fattacker.example",
205 "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM&next=evil",
206 ] {
207 assert!(
208 validate_verification_url(unsafe_url, API, CODE, unsafe_url.contains("user_code"))
209 .is_err(),
210 "accepted unsafe URL: {unsafe_url}"
211 );
212 }
213 assert!(
214 validate_verification_url(
215 "http://localhost:3000/cli/authorize?user_code=ABCD-EFGH-JKLM",
216 "http://127.0.0.1:8787",
217 CODE,
218 true,
219 )
220 .is_ok()
221 );
222 assert!(
223 validate_verification_url(
224 "https://staging-app.example/cli/authorize",
225 "https://staging-api.example",
226 CODE,
227 false,
228 )
229 .is_err()
230 );
231 }
232
233 #[test]
234 fn user_codes_and_key_inputs_match_the_server_contract() {
235 assert!(validate_user_code("ABCD-EFGH-JKLM").is_ok());
236 for invalid in [
237 "CW-1234",
238 "ABCI-EFGH-JKLM",
239 "ABCO-EFGH-JKLM",
240 "ABC1-EFGH-JKLM",
241 "abcd-EFGH-JKLM",
242 "ABCD_EFGH_JKLM",
243 ] {
244 assert!(validate_user_code(invalid).is_err(), "accepted {invalid}");
245 }
246
247 assert!(validate_device_code("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").is_ok());
248 for invalid in [
249 "too-short",
250 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
251 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA!",
252 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
253 ] {
254 assert!(validate_device_code(invalid).is_err(), "accepted {invalid}");
255 }
256
257 assert!(validate_api_key("1234567").is_err());
258 assert!(validate_api_key("12345678").is_ok());
259 assert!(validate_api_key(&"x".repeat(4096)).is_ok());
260 assert!(validate_api_key(&"x".repeat(4097)).is_err());
261 assert!(validate_api_key(&"é".repeat(4)).is_ok());
262 assert!(validate_api_key("1234567\n8").is_err());
263 assert_eq!(
264 parse_key_input(format!("{}\n", "x".repeat(4096)).into_bytes()).unwrap(),
265 "x".repeat(4096)
266 );
267 assert!(parse_key_input(vec![b'x'; MAX_API_KEY_STDIN_BYTES as usize + 1]).is_err());
268 assert_eq!(
269 validate_label(" Codewhale\tCLI ").unwrap(),
270 "Codewhale CLI"
271 );
272 assert!(validate_label(&"x".repeat(80)).is_ok());
273 assert!(validate_label(&"x".repeat(81)).is_err());
274 }
275
276 #[test]
277 fn file_session_store_requires_explicit_one_value() {
278 assert!(!file_session_store_opted_in_value(None));
279 assert!(!file_session_store_opted_in_value(Some("")));
280 assert!(!file_session_store_opted_in_value(Some("true")));
281 assert!(file_session_store_opted_in_value(Some("1")));
282 assert!(file_session_store_opted_in_value(Some(" 1 ")));
283 }
284
285 #[test]
286 fn device_flow_handles_pending_then_authorized_without_printing_tokens() {
287 let (temp, config) = test_config();
288 let _keep_temp = temp;
289 let (secrets, _) = test_secrets();
290 let transport = FakeTransport::new(vec![
291 response(
292 200,
293 json!({
294 "deviceCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
295 "userCode": "ABCD-EFGH-JKLM",
296 "verificationUri": "https://app.codewhale.net/cli/authorize",
297 "verificationUriComplete": "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
298 "expiresIn": 600,
299 "interval": 1
300 }),
301 ),
302 response(202, json!({ "status": "authorization_pending" })),
303 response(
304 200,
305 auth_json("access-never-print", "refresh-never-print", "acct-123"),
306 ),
307 response(200, account("acct-123")),
308 ]);
309 let mut output = Vec::new();
310 let mut key_reader = |_| bail!("key reader should not be called");
311 let mut opened = Vec::new();
312 let mut opener = |url: String| {
313 opened.push(url);
314 true
315 };
316 let mut sleeper = |_| {};
317 run_with(
318 command(&["codewhale", "cloud", "login"]),
319 "work",
320 "https://api.codewhale.net",
321 &config,
322 &secrets,
323 &secrets,
324 &transport,
325 &mut output,
326 &mut key_reader,
327 &mut opener,
328 &mut sleeper,
329 )
330 .unwrap();
331
332 let output = String::from_utf8(output).unwrap();
333 assert!(output.contains("ABCD-EFGH-JKLM"));
334 assert!(output.contains("Account ID: acct-123"));
335 assert!(output.contains("Profile: work"));
336 assert!(!output.contains("access-never-print"));
337 assert!(!output.contains("refresh-never-print"));
338 assert_eq!(opened.len(), 1);
339 let requests = transport.requests();
340 assert_eq!(requests[0].path, "/api/cli/device/start");
341 assert_eq!(requests[1].path, "/api/cli/device/token");
342 assert_eq!(requests[2].path, "/api/cli/device/token");
343 assert_eq!(requests[3].path, "/api/me");
344 }
345
346 #[test]
347 fn cloud_sessions_are_isolated_by_profile_and_api_origin() {
348 let (secrets, _) = test_secrets();
349 let transport = FakeTransport::new(vec![]);
350 let default = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
351 let work = CloudClient::new(&transport, &secrets, "work", "https://api.codewhale.net");
352 let local = CloudClient::new(&transport, &secrets, "default", "http://127.0.0.1:8787");
353 default
354 .save_auth(auth("a-default", "r-default", "acct-default"))
355 .unwrap();
356 work.save_auth(auth("a-work", "r-work", "acct-work"))
357 .unwrap();
358 local
359 .save_auth(auth("a-local", "r-local", "acct-local"))
360 .unwrap();
361
362 assert_eq!(
363 default
364 .load_auth()
365 .unwrap()
366 .unwrap()
367 .bundle
368 .user
369 .unwrap()
370 .id,
371 "acct-default"
372 );
373 assert_eq!(
374 work.load_auth().unwrap().unwrap().bundle.user.unwrap().id,
375 "acct-work"
376 );
377 assert_eq!(
378 local.load_auth().unwrap().unwrap().bundle.user.unwrap().id,
379 "acct-local"
380 );
381 }
382
383 #[test]
384 fn status_refreshes_once_on_unauthorized_and_never_displays_tokens() {
385 let (temp, config) = test_config();
386 let _keep_temp = temp;
387 let (secrets, _) = test_secrets();
388 let transport = FakeTransport::new(vec![
389 response(401, json!({ "code": "access_token_expired" })),
390 response(
391 200,
392 auth_json("access-new-secret", "refresh-new-secret", "acct-refresh"),
393 ),
394 response(200, account("acct-refresh")),
395 ]);
396 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
397 .save_auth(auth(
398 "access-old-secret",
399 "refresh-old-secret",
400 "acct-refresh",
401 ))
402 .unwrap();
403 let mut output = Vec::new();
404 let mut key_reader = |_| bail!("unused");
405 let mut opener = |_| true;
406 let mut sleeper = |_| {};
407 run_with(
408 CloudCommand::Status,
409 "default",
410 "https://api.codewhale.net",
411 &config,
412 &secrets,
413 &secrets,
414 &transport,
415 &mut output,
416 &mut key_reader,
417 &mut opener,
418 &mut sleeper,
419 )
420 .unwrap();
421 let output = String::from_utf8(output).unwrap();
422 assert!(output.contains("acct-refresh"));
423 for secret in [
424 "access-old-secret",
425 "refresh-old-secret",
426 "access-new-secret",
427 "refresh-new-secret",
428 ] {
429 assert!(!output.contains(secret));
430 }
431 let requests = transport.requests();
432 assert_eq!(requests[0].path, "/api/me");
433 assert_eq!(requests[1].path, "/api/auth/refresh");
434 assert_eq!(requests[2].path, "/api/me");
435 }
436
437 #[test]
438 fn non_terminal_refresh_responses_preserve_the_local_session() {
439 for status in [403, 429, 500, 503] {
440 let (secrets, _) = test_secrets();
441 let transport = FakeTransport::new(vec![
442 response(401, json!({ "code": "access_token_expired" })),
443 response(status, json!({ "code": "temporarily_unavailable" })),
444 ]);
445 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
446 client
447 .save_auth(auth(
448 "access-old-secret",
449 "refresh-still-valid",
450 "acct-refresh",
451 ))
452 .unwrap();
453
454 let error = client
455 .me()
456 .err()
457 .expect("refresh response should fail the request")
458 .to_string();
459 assert!(error.contains(&format!("HTTP {status}")));
460 assert_eq!(
461 client
462 .load_auth()
463 .unwrap()
464 .expect("retryable refresh failure must preserve the session")
465 .bundle
466 .refresh_token,
467 "refresh-still-valid"
468 );
469 let requests = transport.requests();
470 assert_eq!(requests.len(), 2);
471 assert_eq!(requests[0].path, "/api/me");
472 assert_eq!(requests[1].path, "/api/auth/refresh");
473 }
474 }
475
476 #[test]
477 fn refresh_transport_failure_preserves_the_local_session() {
478 let (secrets, _) = test_secrets();
479 let transport = FakeTransport::new(vec![response(
480 401,
481 json!({ "code": "access_token_expired" }),
482 )]);
483 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
484 client
485 .save_auth(auth(
486 "access-old-secret",
487 "refresh-still-valid",
488 "acct-refresh",
489 ))
490 .unwrap();
491
492 let error = client
493 .me()
494 .err()
495 .expect("refresh transport should fail")
496 .to_string();
497 assert!(error.contains("fake transport exhausted"));
498 assert_eq!(
499 client
500 .load_auth()
501 .unwrap()
502 .expect("transport failure must preserve the session")
503 .bundle
504 .refresh_token,
505 "refresh-still-valid"
506 );
507 let requests = transport.requests();
508 assert_eq!(requests.len(), 2);
509 assert_eq!(requests[1].path, "/api/auth/refresh");
510 }
511
512 #[test]
513 fn terminal_refresh_auth_failures_clear_the_local_session() {
514 let (secrets, _) = test_secrets();
515 let transport = FakeTransport::new(vec![
516 response(401, json!({ "code": "access_token_expired" })),
517 response(401, json!({ "code": "invalid_refresh_token" })),
518 ]);
519 let client = CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net");
520 client
521 .save_auth(auth(
522 "access-old-secret",
523 "refresh-terminal-secret",
524 "acct-refresh",
525 ))
526 .unwrap();
527
528 let error = client
529 .me()
530 .err()
531 .expect("terminal refresh response should fail the request")
532 .to_string();
533 assert!(error.contains("session expired"));
534 assert!(
535 client.load_auth().unwrap().is_none(),
536 "HTTP 401 must clear the terminal session"
537 );
538 }
539
540 #[test]
541 fn set_list_and_remove_use_account_routes_without_secret_output() {
542 let (temp, config) = test_config();
543 let _keep_temp = temp;
544 let (secrets, _) = test_secrets();
545 let list_account = json!({
546 "user": {
547 "id": "acct-keys",
548 "displayName": "Hunter",
549 "email": "hunter@example.test",
550 "modelKeys": {
551 "openai": { "configured": true, "label": "Laptop", "updatedAt": "now" }
552 }
553 }
554 });
555 let transport = FakeTransport::new(vec![
556 response(200, account("acct-keys")),
557 response(200, json!({ "ok": true })),
558 response(200, list_account),
559 response(200, account("acct-keys")),
560 response(204, json!(null)),
561 ]);
562 CloudClient::new(&transport, &secrets, "default", "https://api.codewhale.net")
563 .save_auth(auth("access-secret", "refresh-secret", "acct-keys"))
564 .unwrap();
565 let mut output = Vec::new();
566 let mut key_reader = |_| Ok("sk-provider-never-print".to_string());
567 let mut opener = |_| true;
568 let mut sleeper = |_| {};
569 for cmd in [
570 command(&[
571 "codewhale",
572 "cloud",
573 "keys",
574 "set",
575 "openai",
576 "--api-key-stdin",
577 "--label",
578 "Laptop",
579 ]),
580 command(&["codewhale", "cloud", "keys", "list"]),
581 command(&["codewhale", "cloud", "keys", "remove", "openai"]),
582 ] {
583 run_with(
584 cmd,
585 "default",
586 "https://api.codewhale.net",
587 &config,
588 &secrets,
589 &secrets,
590 &transport,
591 &mut output,
592 &mut key_reader,
593 &mut opener,
594 &mut sleeper,
595 )
596 .unwrap();
597 }
598 let output = String::from_utf8(output).unwrap();
599 assert!(output.contains("openai: set"));
600 assert!(!output.contains("Laptop"));
601 assert!(output.contains("Codewhale account acct-keys"));
602 assert!(!output.contains("sk-provider-never-print"));
603 assert!(!output.contains("access-secret"));
604 assert!(!output.contains("refresh-secret"));
605
606 let requests = transport.requests();
607 let put = requests
608 .iter()
609 .find(|request| request.method == HttpMethod::Put)
610 .unwrap();
611 assert_eq!(put.path, "/api/model-keys/openai");
612 assert_eq!(
613 serde_json::from_slice::<serde_json::Value>(put.body.as_ref().unwrap()).unwrap(),
614 json!({ "key": "sk-provider-never-print", "label": "Laptop" })
615 );
616 assert!(requests.iter().any(|request| {
617 request.method == HttpMethod::Delete && request.path == "/api/model-keys/openai"
618 }));
619 }
620
621 #[test]
622 fn from_local_uses_config_without_printing_or_requiring_an_inline_key() {
623 let temp = tempfile::tempdir().unwrap();
624 let path = temp.path().join("config.toml");
625 let mut config = ConfigStore::load(Some(path)).unwrap();
626 config.config.providers.anthropic.api_key = Some("sk-local-upload-secret".to_string());
627 let (secrets, _) = test_secrets();
628 let transport = FakeTransport::new(vec![
629 response(200, account("acct-local")),
630 response(200, json!({ "ok": true })),
631 ]);
632 CloudClient::new(&transport, &secrets, "work", "https://api.codewhale.net")
633 .save_auth(auth("access", "refresh", "acct-local"))
634 .unwrap();
635 let mut output = Vec::new();
636 let mut key_reader = |_| bail!("from-local must not prompt");
637 let mut opener = |_| true;
638 let mut sleeper = |_| {};
639 run_with(
640 command(&[
641 "codewhale",
642 "cloud",
643 "keys",
644 "set",
645 "anthropic",
646 "--from-local",
647 ]),
648 "work",
649 "https://api.codewhale.net",
650 &config,
651 &secrets,
652 &secrets,
653 &transport,
654 &mut output,
655 &mut key_reader,
656 &mut opener,
657 &mut sleeper,
658 )
659 .unwrap();
660 let output = String::from_utf8(output).unwrap();
661 assert!(output.contains("acct-local"));
662 assert!(!output.contains("sk-local-upload-secret"));
663 let requests = transport.requests();
664 let put = requests
665 .iter()
666 .find(|request| request.method == HttpMethod::Put)
667 .unwrap();
668 assert!(String::from_utf8_lossy(put.body.as_ref().unwrap()).contains("sk-local-upload-secret"));
669 }
670
671 #[test]
672 fn from_local_uses_config_before_the_provider_secret_store() {
673 let (temp, mut config) = test_config();
674 let _keep_temp = temp;
675 let (secrets, store) = test_secrets();
676 store.set("openai", "sk-secret-store").unwrap();
677
678 assert_eq!(
679 resolve_local_key(&config, &secrets, CloudProvider::Openai)
680 .unwrap()
681 .as_deref(),
682 Some("sk-secret-store")
683 );
684 config.config.providers.openai.api_key = Some("sk-config-first".to_string());
685 assert_eq!(
686 resolve_local_key(&config, &secrets, CloudProvider::Openai)
687 .unwrap()
688 .as_deref(),
689 Some("sk-config-first")
690 );
691 }
692
693 #[test]
694 fn logout_recovers_from_a_corrupt_local_session_record() {
695 let (temp, config) = test_config();
696 let _keep_temp = temp;
697 let (secrets, store) = test_secrets();
698 let slot = cloud_auth_slot("default", "https://api.codewhale.net");
699 store.set(&slot, "not-json-and-not-a-token").unwrap();
700 let transport = FakeTransport::new(vec![]);
701 let mut output = Vec::new();
702 let mut key_reader = |_| bail!("unused");
703 let mut opener = |_| true;
704 let mut sleeper = |_| {};
705 run_with(
706 CloudCommand::Logout,
707 "default",
708 "https://api.codewhale.net",
709 &config,
710 &secrets,
711 &secrets,
712 &transport,
713 &mut output,
714 &mut key_reader,
715 &mut opener,
716 &mut sleeper,
717 )
718 .unwrap();
719 assert!(store.get(&slot).unwrap().is_none());
720 assert!(
721 !String::from_utf8(output)
722 .unwrap()
723 .contains("not-json-and-not-a-token")
724 );
725 }
726
727 #[test]
728 fn logout_clears_obsolete_or_wrong_origin_session_records() {
729 let canonical_api_base = "https://api.codewhale.net";
730 for (case, schema_version, stored_api_base) in [
731 (
732 "obsolete schema",
733 ACCOUNT_SESSION_SCHEMA_VERSION.saturating_add(1),
734 canonical_api_base,
735 ),
736 (
737 "wrong origin",
738 ACCOUNT_SESSION_SCHEMA_VERSION,
739 "https://other.codewhale.net",
740 ),
741 ] {
742 let (secrets, store) = test_secrets();
743 let slot = cloud_auth_slot("default", canonical_api_base);
744 let raw = serde_json::to_string(&StoredCloudAuth {
745 schema_version,
746 api_base: stored_api_base.to_string(),
747 bundle: auth("access-obsolete", "refresh-obsolete", "acct-obsolete"),
748 })
749 .unwrap();
750 store.set(&slot, &raw).unwrap();
751 let transport = FakeTransport::new(vec![]);
752 let client = CloudClient::new(&transport, &secrets, "default", canonical_api_base);
753
754 assert!(
755 client.load_auth().unwrap().is_none(),
756 "{case} must continue to load as signed out"
757 );
758 assert!(!client.logout().unwrap());
759 assert!(
760 store.get(&slot).unwrap().is_none(),
761 "logout must scrub the {case} record"
762 );
763 assert!(transport.requests().is_empty());
764 }
765 }
766
767 #[test]
768 fn server_errors_never_echo_response_messages() {
769 let error = response_error(&response(
770 400,
771 json!({
772 "error": {
773 "code": "invalid_api_key",
774 "message": "The submitted key was sk-never-echo-this"
775 }
776 }),
777 ))
778 .to_string();
779 assert!(error.contains("invalid_api_key"));
780 assert!(!error.contains("sk-never-echo-this"));
781 }
782
783 #[test]
784 fn cloud_auth_slot_does_not_embed_profile_or_origin() {
785 let slot = cloud_auth_slot("private-profile", "https://api.codewhale.net");
786 assert!(!slot.contains("private-profile"));
787 assert!(!slot.contains("api.codewhale.net"));
788 assert_ne!(
789 slot,
790 cloud_auth_slot("other-profile", "https://api.codewhale.net")
791 );
792 }
793
794 #[test]
795 fn fake_store_is_profile_safe() {
796 let (_, store) = test_secrets();
797 store.set("unrelated", "keep-me").unwrap();
798 store.delete("missing").unwrap();
799 assert_eq!(store.get("unrelated").unwrap().as_deref(), Some("keep-me"));
800 }
801
802 #[test]
803 fn account_login_timeout_fails_the_command() {
804 // §2.3 / #5033 class: a timed-out device login printed the timeout yet the
805 // process exited 0. Pin the contract at the run_with seam — the command
806 // must return Err so run_cli maps it to ExitCode::FAILURE. Verified live
807 // against a stub server: `error: Codewhale account login timed out` now
808 // exits 1.
809 let (temp, config) = test_config();
810 let _keep_temp = temp;
811 let (secrets, _) = test_secrets();
812 // Device start succeeds once; every token poll stays pending forever.
813 struct PendingLogin;
814 impl CloudTransport for PendingLogin {
815 fn execute(&self, request: CloudRequest) -> Result<CloudResponse> {
816 if request.path == "/api/cli/device/start" {
817 return Ok(response(
818 200,
819 json!({
820 "deviceCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
821 "userCode": "ABCD-EFGH-JKLM",
822 "verificationUri": "https://app.codewhale.net/cli/authorize",
823 "verificationUriComplete": "https://app.codewhale.net/cli/authorize?user_code=ABCD-EFGH-JKLM",
824 "expiresIn": 600,
825 "interval": 1
826 }),
827 ));
828 }
829 Ok(response(202, json!({ "status": "authorization_pending" })))
830 }
831 }
832 let pending = PendingLogin;
833 let mut output = Vec::new();
834 let mut key_reader = |_| bail!("key reader should not be called");
835 let mut opener = |_| true;
836 // A real (short) sleep keeps the pending loop from busy-spinning while
837 // still reaching the 1s client timeout quickly.
838 let mut sleeper = |duration: std::time::Duration| {
839 std::thread::sleep(duration.min(std::time::Duration::from_millis(50)))
840 };
841 let result = run_with(
842 command(&[
843 "codewhale",
844 "cloud",
845 "login",
846 "--no-open",
847 "--timeout-seconds",
848 "1",
849 ]),
850 "default",
851 "https://api.codewhale.net",
852 &config,
853 &secrets,
854 &secrets,
855 &pending,
856 &mut output,
857 &mut key_reader,
858 &mut opener,
859 &mut sleeper,
860 );
861 let err = match result {
862 Ok(()) => panic!("a timed-out login must return Err so the exit code is non-zero"),
863 Err(err) => err,
864 };
865 assert!(
866 err.to_string().contains("login timed out"),
867 "timeout error text: {err}"
868 );
869 }
870
870 lines RUST