1use url::Url;
11
12use super::sm_api::{LoginFlow, SmAccount, SmApiClient, SmUser};
13use super::{AuthError, DeviceFlowClient, LoopbackFlowClient, TokenSet, default_http_client};
14
15#[derive(Clone)]
19pub struct MintedCredentials {
20 pub account_id: Option<u64>,
23 pub email: Option<String>,
24 pub api_key: String,
26 pub api_secret: String,
28 pub api_url: String,
30 pub refresh_token: Option<String>,
32 pub capi_key_name: String,
34 pub redisctl_key_count: usize,
37 pub account_name: Option<String>,
39 pub superseded_revoked: Option<bool>,
41 pub superseded_key_name: Option<String>,
43 pub capi_newly_enabled: bool,
46 pub accounts: Vec<LoginAccount>,
50}
51
52pub struct SupersededKey {
59 pub account_id: u64,
60 pub key_name: String,
61}
62
63pub enum AccountChoice {
64 Current,
66 Id(u64),
68 Prompt(AccountPrompt),
71}
72
73pub type AccountPrompt =
75 Box<dyn Fn(&[LoginAccount], Option<u64>) -> Result<u64, AuthError> + Send + Sync>;
76
77impl std::fmt::Debug for AccountChoice {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 Self::Current => f.write_str("Current"),
81 Self::Id(id) => write!(f, "Id({id})"),
82 Self::Prompt(_) => f.write_str("Prompt(..)"),
83 }
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct LoginAccount {
90 pub id: u64,
91 pub name: Option<String>,
92}
93
94impl LoginAccount {
95 pub fn label(&self) -> String {
98 match &self.name {
99 Some(n) => format!("{} (#{})", n, self.id),
100 None => format!("#{}", self.id),
101 }
102 }
103}
104
105impl MintedCredentials {
106 pub fn account_count(&self) -> usize {
108 self.accounts.len()
109 }
110
111 pub fn account_label(&self) -> String {
117 self.account_id
118 .and_then(|id| self.accounts.iter().find(|a| a.id == id))
119 .map(LoginAccount::label)
120 .unwrap_or_else(|| "your current account".to_string())
121 }
122}
123
124impl std::fmt::Debug for MintedCredentials {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("MintedCredentials")
127 .field("account_id", &self.account_id)
128 .field("email", &self.email)
129 .field("api_key", &"<redacted>")
130 .field("api_secret", &"<redacted>")
131 .field("api_url", &self.api_url)
132 .field(
133 "refresh_token",
134 &self.refresh_token.as_ref().map(|_| "<redacted>"),
135 )
136 .field("capi_key_name", &self.capi_key_name)
137 .field("redisctl_key_count", &self.redisctl_key_count)
138 .field("account_name", &self.account_name)
139 .field("capi_newly_enabled", &self.capi_newly_enabled)
140 .field("superseded_revoked", &self.superseded_revoked)
141 .field("superseded_key_name", &self.superseded_key_name)
142 .field("accounts", &self.accounts)
143 .finish()
144 }
145}
146
147#[derive(Clone)]
149pub struct CloudAuthenticator {
150 issuer: Url,
151 client_id: String,
152 sm_api_url: Url,
153 capi_url: String,
154 http: reqwest::Client,
155}
156
157impl CloudAuthenticator {
158 pub fn new(
161 issuer: Url,
162 client_id: impl Into<String>,
163 sm_api_url: Url,
164 capi_url: impl Into<String>,
165 ) -> Self {
166 Self {
167 issuer,
168 client_id: client_id.into(),
169 sm_api_url,
170 capi_url: capi_url.into(),
171 http: default_http_client(),
172 }
173 }
174
175 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
177 self.http = http;
178 self
179 }
180
181 pub fn device(&self) -> DeviceFlowClient {
184 DeviceFlowClient::new(self.issuer.clone(), self.client_id.clone())
185 }
186
187 pub fn loopback(&self) -> LoopbackFlowClient {
189 LoopbackFlowClient::new(self.issuer.clone(), self.client_id.clone())
190 }
191
192 pub async fn refresh(&self, refresh_token: &str) -> Result<TokenSet, AuthError> {
195 super::oidc::refresh(&self.issuer, &self.client_id, refresh_token).await
196 }
197
198 pub async fn revoke_refresh_token(&self, refresh_token: &str) -> Result<(), AuthError> {
200 super::oidc::revoke_refresh_token(&self.issuer, &self.client_id, refresh_token).await
201 }
202
203 pub async fn list_accounts<F>(
205 &self,
206 tokens: &TokenSet,
207 mut mfa_prompt: F,
208 ) -> Result<AccountListing, AuthError>
209 where
210 F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
211 {
212 let mut sm = SmApiClient::with_http_client(
213 self.sm_api_url.clone(),
214 self.http.clone(),
215 LoginFlow::Switch,
216 );
217 match sm.login(&tokens.access_token, None).await {
218 Ok(()) => {}
219 Err(AuthError::MfaRequired { factors }) => {
220 self.satisfy_mfa(&mut sm, tokens, &factors, &mut mfa_prompt)
221 .await?
222 }
223 Err(e) => return Err(e),
224 }
225 let user = sm.fetch_current_user().await?;
226 let current = user
227 .current_account_id
228 .as_deref()
229 .and_then(|s| s.parse::<u64>().ok());
230 Ok(AccountListing {
231 email: user.email,
232 accounts: login_accounts(&sm.fetch_accounts().await?),
233 session_account: current,
234 })
235 }
236
237 pub async fn revoke_capi_key(
246 &self,
247 tokens: &TokenSet,
248 account_id: Option<u64>,
249 key_name: &str,
250 ) -> Result<bool, AuthError> {
251 let mut sm = SmApiClient::with_http_client(
252 self.sm_api_url.clone(),
253 self.http.clone(),
254 LoginFlow::Switch,
255 );
256 sm.login(&tokens.access_token, None).await?;
257 if let Some(account_id) = account_id {
261 set_current_account_verified(&sm, account_id).await?;
262 }
263 let entries = sm.fetch_capi_key_entries().await?;
264 let Some((id, _)) = entries.iter().find(|(_, name)| name == key_name) else {
265 tracing::warn!(
268 "key {key_name} is not on {}; it holds: {}",
269 match account_id {
270 Some(account) => format!("account {account}"),
271 None => "the account this sign-in defaults to".to_string(),
272 },
273 entries
274 .iter()
275 .map(|(_, name)| name.as_str())
276 .collect::<Vec<_>>()
277 .join(", ")
278 );
279 return Ok(false);
280 };
281 sm.delete_capi_key(*id).await?;
282 Ok(true)
283 }
284
285 pub async fn complete_login(
290 &self,
291 tokens: &TokenSet,
292 key_name: &str,
293 flow: LoginFlow,
294 account: AccountChoice,
295 ) -> Result<MintedCredentials, AuthError> {
296 self.complete_login_with_mfa(tokens, key_name, flow, account, None, |_, _| Ok(None))
297 .await
298 .map(|(creds, _)| creds)
299 }
300
301 pub async fn complete_login_with_mfa<F>(
309 &self,
310 tokens: &TokenSet,
311 key_name: &str,
312 flow: LoginFlow,
313 account: AccountChoice,
314 superseded: Option<SupersededKey>,
315 mut mfa_prompt: F,
316 ) -> Result<(MintedCredentials, Option<SupersededRevoker>), AuthError>
317 where
318 F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
319 {
320 let mut sm =
321 SmApiClient::with_http_client(self.sm_api_url.clone(), self.http.clone(), flow);
322 match sm.login(&tokens.access_token, None).await {
324 Ok(()) => {}
325 Err(AuthError::MfaRequired { factors }) => {
326 self.satisfy_mfa(&mut sm, tokens, &factors, &mut mfa_prompt)
327 .await?
328 }
329 Err(e) => return Err(e),
330 }
331 let mut user = sm.fetch_current_user().await?;
332 let want = match account {
333 AccountChoice::Current => None,
334 AccountChoice::Id(id) => Some(id),
335 AccountChoice::Prompt(choose) => {
338 let accounts = login_accounts(&sm.fetch_accounts().await?);
339 let current = user
340 .current_account_id
341 .as_deref()
342 .and_then(|s| s.parse::<u64>().ok());
343 Some(choose(&accounts, current)?)
344 }
345 };
346 if let Some(want) = want {
347 user = self.switch_account(&sm, user, want).await?;
348 }
349 let chosen = resolve_account(
356 sm.fetch_accounts().await?,
357 user.current_account_id.as_deref(),
358 )?
359 .id;
360 let capi_newly_enabled = sm.ensure_capi_enabled().await?;
361 let accounts = sm.fetch_accounts().await?;
364 let all_accounts = login_accounts(&accounts);
365 let account = accounts
366 .into_iter()
367 .find(|a| a.id == chosen)
368 .ok_or_else(|| {
369 AuthError::Protocol(format!(
370 "account {chosen} was no longer listed after enabling programmatic access"
371 ))
372 })?;
373 let account_name = account.name.clone();
374 let account_id = Some(account.id);
376 let api_key = account.api_access_key.ok_or_else(|| {
377 AuthError::Protocol("account has no CAPI access key after enabling CAPI".into())
378 })?;
379 let minted = sm.mint_capi_key(key_name, user.user_account()?).await?;
380 let redisctl_key_count = sm
383 .fetch_capi_keys()
384 .await
385 .map(|keys| keys.iter().filter(|n| n.starts_with("redisctl-")).count())
386 .unwrap_or(0);
387 Ok((
388 MintedCredentials {
389 account_id,
390 email: user.email,
391 api_key,
392 api_secret: minted.secret_key,
393 api_url: self.capi_url.clone(),
394 refresh_token: tokens.refresh_token.clone(),
395 capi_key_name: minted.name,
396 redisctl_key_count,
397 account_name,
398 capi_newly_enabled,
399 superseded_revoked: None,
401 superseded_key_name: None,
402 accounts: all_accounts,
403 },
404 superseded.map(|previous| SupersededRevoker {
405 sm,
406 previous,
407 on: account_id,
408 }),
409 ))
410 }
411
412 async fn switch_account(
417 &self,
418 sm: &SmApiClient,
419 user: SmUser,
420 want: u64,
421 ) -> Result<SmUser, AuthError> {
422 if user.current_account_id.as_deref() == Some(want.to_string().as_str()) {
423 return Ok(user);
424 }
425 let accounts = sm.fetch_accounts().await?;
429 if !accounts.iter().any(|a| a.id == want) {
430 if accounts.is_empty() {
431 return Err(AuthError::Protocol(
432 "this login is not associated with any Redis Cloud account, so there is \
433 nothing to switch to"
434 .into(),
435 ));
436 }
437 return Err(AuthError::UnknownAccount {
438 requested: want,
439 available: account_labels(&accounts),
440 });
441 }
442 set_current_account_verified(sm, want).await
443 }
444
445 async fn satisfy_mfa<F>(
447 &self,
448 sm: &mut SmApiClient,
449 tokens: &TokenSet,
450 factors: &[String],
451 mfa_prompt: &mut F,
452 ) -> Result<(), AuthError>
453 where
454 F: FnMut(&[String], u32) -> Result<Option<String>, AuthError>,
455 {
456 for attempt in 1..=MFA_MAX_ATTEMPTS {
457 let Some(code) = mfa_prompt(factors, attempt)? else {
458 return Err(AuthError::MfaRequired {
460 factors: factors.to_vec(),
461 });
462 };
463 match sm.complete_mfa(&tokens.access_token, None, &code).await {
464 Ok(()) => return Ok(()),
465 Err(AuthError::MfaInvalidCode) if attempt < MFA_MAX_ATTEMPTS => continue,
467 Err(e) => return Err(e),
468 }
469 }
470 Err(AuthError::MfaInvalidCode)
471 }
472}
473
474pub const MFA_MAX_ATTEMPTS: u32 = 3;
477
478fn login_accounts(accounts: &[SmAccount]) -> Vec<LoginAccount> {
486 let mut out: Vec<LoginAccount> = accounts
487 .iter()
488 .map(|a| LoginAccount {
489 id: a.id,
490 name: a.name.clone(),
491 })
492 .collect();
493 out.sort_by_key(|a| a.id);
494 out
495}
496
497#[derive(Debug)]
499pub struct AccountListing {
500 pub email: Option<String>,
501 pub accounts: Vec<LoginAccount>,
502 pub session_account: Option<u64>,
505}
506
507async fn set_current_account_verified(sm: &SmApiClient, want: u64) -> Result<SmUser, AuthError> {
513 sm.set_current_account(want).await?;
514 let user = sm.fetch_current_user().await?;
515 if user.current_account_id.as_deref() != Some(want.to_string().as_str()) {
517 return Err(AuthError::Protocol(format!(
518 "asked Redis Cloud to switch to account {want} but the session still reports {}",
519 user.current_account_id.as_deref().unwrap_or("none")
520 )));
521 }
522 Ok(user)
523}
524
525pub struct SupersededRevoker {
531 sm: SmApiClient,
532 previous: SupersededKey,
533 on: Option<u64>,
536}
537
538impl SupersededRevoker {
539 pub fn key_name(&self) -> &str {
541 &self.previous.key_name
542 }
543
544 pub fn account_id(&self) -> u64 {
547 self.previous.account_id
548 }
549
550 pub async fn revoke(self) -> bool {
552 revoke_superseded(&self.sm, &self.previous, self.on).await
553 }
554}
555
556async fn revoke_superseded(sm: &SmApiClient, previous: &SupersededKey, on: Option<u64>) -> bool {
559 let account = previous.account_id;
560 let moved = on != Some(account);
561 if moved && let Err(e) = set_current_account_verified(sm, account).await {
562 tracing::warn!(
563 "cannot reach account {account} to revoke key {}: {e}",
564 previous.key_name
565 );
566 return false;
567 }
568 delete_named_key(sm, account, &previous.key_name).await
571}
572
573async fn delete_named_key(sm: &SmApiClient, account: u64, key: &str) -> bool {
574 let entries = match sm.fetch_capi_key_entries().await {
575 Ok(entries) => entries,
576 Err(e) => {
577 tracing::warn!("cannot list keys on account {account} to revoke {key}: {e}");
578 return false;
579 }
580 };
581 let Some((id, _)) = entries.iter().find(|(_, name)| name == key) else {
582 tracing::warn!(
583 "key {key} is not on account {account}; it holds: {}",
584 entries
585 .iter()
586 .map(|(_, name)| name.as_str())
587 .collect::<Vec<_>>()
588 .join(", ")
589 );
590 return false;
591 };
592 match sm.delete_capi_key(*id).await {
593 Ok(()) => true,
594 Err(e) => {
595 tracing::warn!("could not delete key {key} ({id}) on account {account}: {e}");
596 false
597 }
598 }
599}
600
601fn resolve_account(
608 mut accounts: Vec<SmAccount>,
609 current_account_id: Option<&str>,
610) -> Result<SmAccount, AuthError> {
611 let target = current_account_id.and_then(|s| s.parse::<u64>().ok());
612 if let Some(at) = target.and_then(|id| accounts.iter().position(|a| a.id == id)) {
613 return Ok(accounts.swap_remove(at));
614 }
615 if accounts.is_empty() {
616 return Err(AuthError::Protocol(
617 "no accounts associated with this login".into(),
618 ));
619 }
620 if accounts.len() == 1 && current_account_id.is_none() {
625 return Ok(accounts.swap_remove(0));
626 }
627 Err(AuthError::AccountRequired(format!(
628 "this sign-in does not report which Redis Cloud account is current{}, and a key minted \
629 on a guess could belong to the wrong one. Re-run with `--account <id>`; you belong to: \
630 {}",
631 match current_account_id {
632 Some(id) => format!(" (it names {id}, which is not one of yours)"),
633 None => String::new(),
634 },
635 account_labels(&accounts)
636 )))
637}
638
639fn account_labels(accounts: &[SmAccount]) -> String {
641 login_accounts(accounts)
642 .iter()
643 .map(LoginAccount::label)
644 .collect::<Vec<_>>()
645 .join(", ")
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use wiremock::matchers::{method, path};
652 use wiremock::{Mock, MockServer, ResponseTemplate};
653
654 fn account(id: u64) -> SmAccount {
655 serde_json::from_value(serde_json::json!({
656 "id": id, "api_access_key": format!("KEY-{id}")
657 }))
658 .unwrap()
659 }
660
661 #[test]
664 fn login_account_labels_named_and_unnamed_accounts() {
665 assert_eq!(
666 LoginAccount {
667 id: 316941,
668 name: Some("Acme".to_string()),
669 }
670 .label(),
671 "Acme (#316941)"
672 );
673 assert_eq!(
674 LoginAccount {
675 id: 316941,
676 name: None,
677 }
678 .label(),
679 "#316941"
680 );
681 }
682
683 #[test]
684 fn resolve_account_prefers_current_account_id() {
685 let accts = vec![account(111), account(222), account(333)];
686 let chosen = resolve_account(accts, Some("222")).unwrap();
688 assert_eq!(chosen.id, 222);
689 }
690
691 #[test]
695 fn resolve_account_refuses_to_guess_between_several() {
696 for current in [None, Some("999")] {
697 let err = resolve_account(vec![account(111), account(222)], current).unwrap_err();
698 let AuthError::AccountRequired(message) = err else {
699 panic!("{current:?} gave {err:?}");
700 };
701 assert!(message.contains("#111"), "{message}");
702 assert!(message.contains("#222"), "{message}");
703 assert!(message.contains("--account"), "{message}");
704 }
705 let err = resolve_account(vec![account(111), account(222)], Some("999")).unwrap_err();
707 assert!(err.to_string().contains("999"), "{err}");
708 }
709
710 #[test]
713 fn resolve_account_takes_the_only_account() {
714 assert_eq!(resolve_account(vec![account(111)], None).unwrap().id, 111);
715 assert_eq!(
717 resolve_account(vec![account(111)], Some("111")).unwrap().id,
718 111
719 );
720 }
721
722 #[test]
726 fn resolve_account_refuses_a_lone_account_the_session_disowns() {
727 for current in [Some("999"), Some("not-a-number")] {
728 let err = resolve_account(vec![account(111)], current).unwrap_err();
729 let AuthError::AccountRequired(message) = err else {
730 panic!("{current:?} gave {err:?}");
731 };
732 assert!(message.contains("#111"), "{message}");
733 assert!(message.contains("--account"), "{message}");
734 }
735 }
736
737 #[test]
738 fn resolve_account_reports_an_empty_list_as_protocol() {
739 assert!(matches!(
740 resolve_account(vec![], Some("1")),
741 Err(AuthError::Protocol(_))
742 ));
743 }
744
745 #[test]
746 fn debug_redacts_secrets() {
747 let creds = MintedCredentials {
748 account_id: Some(42),
749 email: Some("u@example.com".to_string()),
750 api_key: "AKEY-visible-should-not-appear".to_string(),
751 api_secret: "SECRET-should-not-appear".to_string(),
752 api_url: "https://api.example.com/v1".to_string(),
753 refresh_token: Some("RT-should-not-appear".to_string()),
754 capi_key_name: "redisctl-cli-1".to_string(),
755 redisctl_key_count: 3,
756 account_name: Some("Acme".to_string()),
757 capi_newly_enabled: false,
758 superseded_revoked: None,
759 superseded_key_name: None,
760 accounts: vec![
761 LoginAccount {
762 id: 316941,
763 name: Some("Acme".to_string()),
764 },
765 LoginAccount {
766 id: 481022,
767 name: Some("Contoso".to_string()),
768 },
769 ],
770 };
771 let dbg = format!("{creds:?}");
772 assert!(dbg.contains("<redacted>"));
773 assert!(!dbg.contains("AKEY-visible-should-not-appear"));
774 assert!(!dbg.contains("SECRET-should-not-appear"));
775 assert!(!dbg.contains("RT-should-not-appear"));
776 assert!(dbg.contains("u@example.com"));
778 assert!(dbg.contains("redisctl-cli-1"));
779 }
780
781 #[tokio::test]
782 async fn complete_login_runs_the_full_exchange() {
783 let server = MockServer::start().await;
784 let mount = |m: &str, p: &'static str, body: serde_json::Value, cookie: bool| {
785 let mut tmpl = ResponseTemplate::new(200).set_body_json(body);
786 if cookie {
787 tmpl = tmpl.append_header("Set-Cookie", "JSESSIONID=SID; Path=/");
788 }
789 Mock::given(method(m)).and(path(p)).respond_with(tmpl)
790 };
791 mount("POST", "/login", serde_json::json!({}), true)
792 .mount(&server)
793 .await;
794 mount(
795 "GET",
796 "/csrf",
797 serde_json::json!({"csrfToken": {"csrf_token": "C"}}),
798 false,
799 )
800 .mount(&server)
801 .await;
802 mount(
803 "GET",
804 "/users/me",
805 serde_json::json!({"id": "114429", "current_account_id": "112117", "email": "u@e.com"}),
806 false,
807 )
808 .mount(&server)
809 .await;
810 mount(
811 "POST",
812 "/accounts/cloud-api/cloudApiAccessKey",
813 serde_json::json!({"cloudApiAccessKey": {"accessKey": "ACCT"}}),
814 false,
815 )
816 .mount(&server)
817 .await;
818 mount(
819 "GET",
820 "/accounts",
821 serde_json::json!({"accounts": [{"id": 112117, "api_access_key": "ACCT-KEY"}]}),
822 false,
823 )
824 .mount(&server)
825 .await;
826 mount(
827 "POST",
828 "/accounts/cloud-api/cloudApiKeys",
829 serde_json::json!({"name": "redisctl-test", "secret_key": "SECRET"}),
830 false,
831 )
832 .mount(&server)
833 .await;
834
835 let auth = CloudAuthenticator::new(
836 Url::parse("https://issuer.example/oauth2/default").unwrap(),
837 "cid",
838 Url::parse(&server.uri()).unwrap(),
839 "https://capi.example/v1",
840 );
841 let tokens = TokenSet {
842 access_token: "AT".into(),
843 refresh_token: Some("RT".into()),
844 expires_in: 3600,
845 };
846
847 let creds = auth
848 .complete_login(
849 &tokens,
850 "redisctl-test",
851 LoginFlow::Loopback,
852 AccountChoice::Current,
853 )
854 .await
855 .unwrap();
856 assert_eq!(creds.api_key, "ACCT-KEY");
857 assert_eq!(creds.api_secret, "SECRET");
858 assert_eq!(creds.api_url, "https://capi.example/v1");
859 assert_eq!(creds.account_id, Some(112117));
860 assert_eq!(creds.email.as_deref(), Some("u@e.com"));
861 assert_eq!(creds.refresh_token.as_deref(), Some("RT"));
862 assert_eq!(creds.capi_key_name, "redisctl-test");
863 let dbg = format!("{creds:?}");
865 assert!(!dbg.contains("SECRET") && !dbg.contains("ACCT-KEY") && !dbg.contains("RT"));
866 }
867
868 fn tokens() -> TokenSet {
869 TokenSet {
870 access_token: "AT".to_string(),
871 refresh_token: None,
872 expires_in: 3600,
873 }
874 }
875
876 fn authenticator(server: &MockServer) -> CloudAuthenticator {
877 CloudAuthenticator::new(
878 Url::parse("https://issuer.example/oauth2/default").unwrap(),
879 "client",
880 Url::parse(&server.uri()).unwrap(),
881 "https://capi.example/v1",
882 )
883 }
884
885 async fn common_login_mocks(server: &MockServer) {
887 Mock::given(method("POST"))
888 .and(path("/login"))
889 .respond_with(
890 ResponseTemplate::new(200)
891 .set_body_json(serde_json::json!({}))
892 .append_header("Set-Cookie", "JSESSIONID=SID; Path=/"),
893 )
894 .mount(server)
895 .await;
896 Mock::given(method("GET"))
897 .and(path("/csrf"))
898 .respond_with(
899 ResponseTemplate::new(200)
900 .set_body_json(serde_json::json!({"csrfToken": {"csrf_token": "C"}})),
901 )
902 .mount(server)
903 .await;
904 Mock::given(method("POST"))
905 .and(path("/accounts/cloud-api/cloudApiAccessKey"))
906 .respond_with(
907 ResponseTemplate::new(200)
908 .set_body_json(serde_json::json!({"cloudApiAccessKey": {"accessKey": "ACCT"}})),
909 )
910 .mount(server)
911 .await;
912 Mock::given(method("POST"))
913 .and(path("/accounts/cloud-api/cloudApiKeys"))
914 .respond_with(ResponseTemplate::new(200).set_body_json(
915 serde_json::json!({"name": "redisctl-test", "secret_key": "SECRET"}),
916 ))
917 .mount(server)
918 .await;
919 }
920
921 #[tokio::test]
927 async fn complete_login_switches_before_minting() {
928 let server = MockServer::start().await;
929 let switched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
930 common_login_mocks(&server).await;
931
932 let flag = switched.clone();
934 Mock::given(method("GET"))
935 .and(path("/users/me"))
936 .respond_with(move |_: &wiremock::Request| {
937 let id = if flag.load(std::sync::atomic::Ordering::SeqCst) {
938 "222"
939 } else {
940 "111"
941 };
942 ResponseTemplate::new(200).set_body_json(serde_json::json!({
943 "id": "1", "current_account_id": id, "email": "u@e.com"
944 }))
945 })
946 .mount(&server)
947 .await;
948 let flag = switched.clone();
949 Mock::given(method("POST"))
950 .and(path("/accounts/setcurrent/222"))
951 .respond_with(move |_: &wiremock::Request| {
952 flag.store(true, std::sync::atomic::Ordering::SeqCst);
953 ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
954 })
955 .mount(&server)
956 .await;
957 Mock::given(method("GET"))
958 .and(path("/accounts"))
959 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
960 "accounts": [
961 {"id": 111, "name": "One", "api_access_key": "KEY-111"},
962 {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
963 ]
964 })))
965 .mount(&server)
966 .await;
967
968 let creds = authenticator(&server)
969 .complete_login(
970 &tokens(),
971 "redisctl-test",
972 LoginFlow::Loopback,
973 AccountChoice::Id(222),
974 )
975 .await
976 .unwrap();
977 assert_eq!(creds.account_id, Some(222));
979 assert_eq!(creds.account_name.as_deref(), Some("Two"));
980 assert_eq!(creds.api_key, "KEY-222");
981 assert_eq!(creds.account_count(), 2);
982 }
983
984 #[tokio::test]
988 async fn complete_login_mints_what_the_picker_chose() {
989 let server = MockServer::start().await;
990 let switched = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
991 common_login_mocks(&server).await;
992
993 let flag = switched.clone();
994 Mock::given(method("GET"))
995 .and(path("/users/me"))
996 .respond_with(move |_: &wiremock::Request| {
997 let id = if flag.load(std::sync::atomic::Ordering::SeqCst) {
998 "111"
999 } else {
1000 "222"
1001 };
1002 ResponseTemplate::new(200).set_body_json(serde_json::json!({
1003 "id": "1", "current_account_id": id, "email": "u@e.com"
1004 }))
1005 })
1006 .mount(&server)
1007 .await;
1008 let flag = switched.clone();
1009 Mock::given(method("POST"))
1010 .and(path("/accounts/setcurrent/111"))
1011 .respond_with(move |_: &wiremock::Request| {
1012 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1013 ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
1014 })
1015 .mount(&server)
1016 .await;
1017 Mock::given(method("GET"))
1019 .and(path("/accounts"))
1020 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1021 "accounts": [
1022 {"id": 222, "name": "Two", "api_access_key": "KEY-222"},
1023 {"id": 111, "name": "One", "api_access_key": "KEY-111"}
1024 ]
1025 })))
1026 .mount(&server)
1027 .await;
1028
1029 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1030 let seen_current = std::sync::Arc::new(std::sync::Mutex::new(None));
1031 let (s2, c2) = (seen.clone(), seen_current.clone());
1032 let creds = authenticator(&server)
1033 .complete_login(
1034 &tokens(),
1035 "k",
1036 LoginFlow::Switch,
1037 AccountChoice::Prompt(Box::new(move |accounts, current| {
1038 *s2.lock().unwrap() = accounts.iter().map(|a| a.id).collect::<Vec<_>>();
1039 *c2.lock().unwrap() = current;
1040 Ok(111)
1041 })),
1042 )
1043 .await
1044 .unwrap();
1045
1046 assert_eq!(*seen.lock().unwrap(), vec![111, 222]);
1048 assert_eq!(*seen_current.lock().unwrap(), Some(222));
1050 assert_eq!(creds.account_id, Some(111));
1052 assert_eq!(creds.api_key, "KEY-111");
1053 assert_eq!(creds.account_label(), "One (#111)");
1054 }
1055
1056 #[tokio::test]
1058 async fn complete_login_propagates_a_declined_picker() {
1059 let server = MockServer::start().await;
1060 common_login_mocks(&server).await;
1061 Mock::given(method("GET"))
1062 .and(path("/users/me"))
1063 .respond_with(ResponseTemplate::new(200).set_body_json(
1064 serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1065 ))
1066 .mount(&server)
1067 .await;
1068 Mock::given(method("GET"))
1069 .and(path("/accounts"))
1070 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1071 "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1072 })))
1073 .mount(&server)
1074 .await;
1075 let err = authenticator(&server)
1076 .complete_login(
1077 &tokens(),
1078 "k",
1079 LoginFlow::Switch,
1080 AccountChoice::Prompt(Box::new(|_, _| {
1081 Err(AuthError::AccountRequired("declined".into()))
1082 })),
1083 )
1084 .await
1085 .unwrap_err();
1086 assert!(matches!(err, AuthError::AccountRequired(_)), "got {err:?}");
1087 }
1088
1089 #[tokio::test]
1092 async fn list_accounts_reads_without_minting_or_switching() {
1093 let server = MockServer::start().await;
1094 Mock::given(method("POST"))
1095 .and(path("/login"))
1096 .respond_with(
1097 ResponseTemplate::new(200)
1098 .set_body_json(serde_json::json!({}))
1099 .append_header("Set-Cookie", "JSESSIONID=SID; Path=/"),
1100 )
1101 .mount(&server)
1102 .await;
1103 Mock::given(method("GET"))
1104 .and(path("/csrf"))
1105 .respond_with(
1106 ResponseTemplate::new(200)
1107 .set_body_json(serde_json::json!({"csrfToken": {"csrf_token": "C"}})),
1108 )
1109 .mount(&server)
1110 .await;
1111 Mock::given(method("GET"))
1112 .and(path("/users/me"))
1113 .respond_with(ResponseTemplate::new(200).set_body_json(
1114 serde_json::json!({"id": "1", "current_account_id": "222", "email": "u@e.com"}),
1115 ))
1116 .mount(&server)
1117 .await;
1118 Mock::given(method("GET"))
1119 .and(path("/accounts"))
1120 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1121 "accounts": [
1122 {"id": 222, "name": "Two"},
1123 {"id": 111, "name": "One"}
1124 ]
1125 })))
1126 .mount(&server)
1127 .await;
1128
1129 let listing = authenticator(&server)
1130 .list_accounts(&tokens(), |_, _| Ok(None))
1131 .await
1132 .unwrap();
1133
1134 assert_eq!(
1136 listing.accounts.iter().map(|a| a.id).collect::<Vec<_>>(),
1137 vec![111, 222]
1138 );
1139 assert_eq!(listing.session_account, Some(222));
1140 assert_eq!(listing.email.as_deref(), Some("u@e.com"));
1141 }
1142
1143 #[tokio::test]
1146 async fn complete_login_refuses_an_account_the_user_is_not_in() {
1147 let server = MockServer::start().await;
1148 common_login_mocks(&server).await;
1149 Mock::given(method("GET"))
1150 .and(path("/users/me"))
1151 .respond_with(ResponseTemplate::new(200).set_body_json(
1152 serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1153 ))
1154 .mount(&server)
1155 .await;
1156 Mock::given(method("GET"))
1157 .and(path("/accounts"))
1158 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1159 "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1160 })))
1161 .mount(&server)
1162 .await;
1163 match authenticator(&server)
1166 .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(999))
1167 .await
1168 {
1169 Err(AuthError::UnknownAccount {
1170 requested,
1171 available,
1172 }) => {
1173 assert_eq!(requested, 999);
1174 assert_eq!(available, "One (#111)");
1175 }
1176 other => panic!("expected UnknownAccount, got {other:?}"),
1177 }
1178 }
1179
1180 #[tokio::test]
1188 async fn complete_login_mints_nothing_when_the_account_is_ambiguous() {
1189 async fn attempt(current: Option<&str>) -> (AuthError, usize) {
1190 let server = MockServer::start().await;
1191 common_login_mocks(&server).await;
1192
1193 let mut me = serde_json::json!({"id": "1", "email": "u@e.com"});
1194 if let Some(current) = current {
1195 me["current_account_id"] = serde_json::json!(current);
1196 }
1197 Mock::given(method("GET"))
1198 .and(path("/users/me"))
1199 .respond_with(ResponseTemplate::new(200).set_body_json(me))
1200 .mount(&server)
1201 .await;
1202 Mock::given(method("GET"))
1203 .and(path("/accounts"))
1204 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1205 "accounts": [
1206 {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1207 {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1208 ]
1209 })))
1210 .mount(&server)
1211 .await;
1212
1213 let err = authenticator(&server)
1214 .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Current)
1215 .await
1216 .expect_err("an ambiguous account must not complete");
1217 let requests = server.received_requests().await.unwrap_or_default();
1218 let hits = |path: &str| requests.iter().filter(|r| r.url.path() == path).count();
1219 assert_eq!(
1220 hits("/accounts/cloud-api/cloudApiAccessKey"),
1221 0,
1222 "{current:?} enabled programmatic access before refusing"
1223 );
1224 (err, hits("/accounts/cloud-api/cloudApiKeys"))
1225 }
1226
1227 for current in [None, Some("999")] {
1228 let (err, mints) = attempt(current).await;
1229 assert!(
1230 matches!(err, AuthError::AccountRequired(_)),
1231 "{current:?} gave {err:?}"
1232 );
1233 assert_eq!(mints, 0, "{current:?} minted a key anyway");
1234 }
1235 }
1236
1237 #[tokio::test]
1240 async fn complete_login_fails_when_the_switch_does_not_take() {
1241 let server = MockServer::start().await;
1242 common_login_mocks(&server).await;
1243 Mock::given(method("GET"))
1245 .and(path("/users/me"))
1246 .respond_with(ResponseTemplate::new(200).set_body_json(
1247 serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1248 ))
1249 .mount(&server)
1250 .await;
1251 Mock::given(method("POST"))
1252 .and(path("/accounts/setcurrent/222"))
1253 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1254 .mount(&server)
1255 .await;
1256 Mock::given(method("GET"))
1257 .and(path("/accounts"))
1258 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1259 "accounts": [
1260 {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1261 {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1262 ]
1263 })))
1264 .mount(&server)
1265 .await;
1266 let err = authenticator(&server)
1267 .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(222))
1268 .await
1269 .unwrap_err();
1270 assert!(
1271 matches!(err, AuthError::Protocol(ref m) if m.contains("still reports")),
1272 "expected a switch-verification failure, got {err:?}"
1273 );
1274 }
1275
1276 #[tokio::test]
1278 async fn complete_login_skips_the_switch_when_already_current() {
1279 let server = MockServer::start().await;
1280 common_login_mocks(&server).await;
1281 Mock::given(method("GET"))
1282 .and(path("/users/me"))
1283 .respond_with(ResponseTemplate::new(200).set_body_json(
1284 serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1285 ))
1286 .mount(&server)
1287 .await;
1288 Mock::given(method("GET"))
1289 .and(path("/accounts"))
1290 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1291 "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1292 })))
1293 .mount(&server)
1294 .await;
1295 let creds = authenticator(&server)
1297 .complete_login(&tokens(), "k", LoginFlow::Loopback, AccountChoice::Id(111))
1298 .await
1299 .unwrap();
1300 assert_eq!(creds.account_id, Some(111));
1301 }
1302
1303 #[tokio::test]
1306 async fn the_mint_defers_revocation_to_the_caller() {
1307 let server = MockServer::start().await;
1308 common_login_mocks(&server).await;
1309 Mock::given(method("GET"))
1310 .and(path("/users/me"))
1311 .respond_with(ResponseTemplate::new(200).set_body_json(
1312 serde_json::json!({"id": "1", "current_account_id": "111", "email": "u@e.com"}),
1313 ))
1314 .mount(&server)
1315 .await;
1316 Mock::given(method("GET"))
1317 .and(path("/accounts"))
1318 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1319 "accounts": [{"id": 111, "name": "One", "api_access_key": "KEY-111"}]
1320 })))
1321 .mount(&server)
1322 .await;
1323 Mock::given(method("GET"))
1324 .and(path("/accounts/cloud-api/cloudApiKeys"))
1325 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1326 "cloudApiKeys": [{"id": 7, "name": "redisctl-cli-1"}]
1327 })))
1328 .mount(&server)
1329 .await;
1330
1331 let (creds, revoker) = authenticator(&server)
1332 .complete_login_with_mfa(
1333 &tokens(),
1334 "redisctl-cli-2",
1335 LoginFlow::Loopback,
1336 AccountChoice::Current,
1337 Some(SupersededKey {
1338 account_id: 111,
1339 key_name: "redisctl-cli-1".to_string(),
1340 }),
1341 |_, _| Ok(None),
1342 )
1343 .await
1344 .unwrap();
1345 assert!(!creds.api_secret.is_empty());
1347 assert_eq!(creds.superseded_revoked, None);
1348 let revoker = revoker.expect("a superseded key was given, so a revoker comes back");
1349 assert_eq!(revoker.key_name(), "redisctl-cli-1");
1350
1351 Mock::given(method("POST"))
1354 .and(path("/accounts/setcurrent/111"))
1355 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1356 .mount(&server)
1357 .await;
1358 Mock::given(method("DELETE"))
1359 .and(path("/accounts/cloud-api/cloudApiKeys/7"))
1360 .respond_with(ResponseTemplate::new(200))
1361 .expect(1)
1362 .mount(&server)
1363 .await;
1364 assert!(revoker.revoke().await);
1365 }
1366
1367 #[tokio::test]
1371 async fn revoking_across_accounts_reaches_the_other_account() {
1372 let server = MockServer::start().await;
1373 let cell = mock_session(&server, 111, vec![111, 222]).await;
1374 mock_keys(&server, cell, vec![(111, 9, "redisctl-cli-1")]).await;
1375 Mock::given(method("GET"))
1376 .and(path("/accounts"))
1377 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1378 "accounts": [
1379 {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1380 {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1381 ]
1382 })))
1383 .mount(&server)
1384 .await;
1385 Mock::given(method("DELETE"))
1386 .and(path("/accounts/cloud-api/cloudApiKeys/9"))
1387 .respond_with(ResponseTemplate::new(200))
1388 .expect(1)
1389 .named("delete the superseded key on the account that holds it")
1390 .mount(&server)
1391 .await;
1392
1393 let (creds, revoker) = authenticator(&server)
1394 .complete_login_with_mfa(
1395 &tokens(),
1396 "redisctl-cli-2",
1397 LoginFlow::Switch,
1398 AccountChoice::Id(222),
1399 Some(SupersededKey {
1400 account_id: 111,
1401 key_name: "redisctl-cli-1".to_string(),
1402 }),
1403 |_, _| Ok(None),
1404 )
1405 .await
1406 .unwrap();
1407 assert_eq!(creds.account_id, Some(222));
1408 assert!(revoker.unwrap().revoke().await);
1409 }
1410
1411 type AccountCell = std::sync::Arc<std::sync::atomic::AtomicU64>;
1412
1413 fn account_of(cell: &AccountCell) -> u64 {
1414 cell.load(std::sync::atomic::Ordering::SeqCst)
1415 }
1416
1417 async fn mock_session(server: &MockServer, starts_on: u64, honoured: Vec<u64>) -> AccountCell {
1424 let current: AccountCell =
1425 std::sync::Arc::new(std::sync::atomic::AtomicU64::new(starts_on));
1426 common_login_mocks(server).await;
1427
1428 let cell = current.clone();
1429 Mock::given(method("GET"))
1430 .and(path("/users/me"))
1431 .respond_with(move |_: &wiremock::Request| {
1432 ResponseTemplate::new(200).set_body_json(serde_json::json!({
1433 "id": "1", "current_account_id": account_of(&cell).to_string(),
1434 "email": "u@e.com"
1435 }))
1436 })
1437 .mount(server)
1438 .await;
1439
1440 let cell = current.clone();
1441 Mock::given(method("POST"))
1442 .and(wiremock::matchers::path_regex(
1443 r"^/accounts/setcurrent/\d+$",
1444 ))
1445 .respond_with(move |req: &wiremock::Request| {
1446 if let Some(asked) = req
1447 .url
1448 .path()
1449 .rsplit('/')
1450 .next()
1451 .and_then(|s| s.parse::<u64>().ok())
1452 && honoured.contains(&asked)
1453 {
1454 cell.store(asked, std::sync::atomic::Ordering::SeqCst);
1455 }
1456 ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
1457 })
1458 .mount(server)
1459 .await;
1460
1461 current
1462 }
1463
1464 async fn mock_keys(server: &MockServer, cell: AccountCell, per_account: Vec<(u64, u64, &str)>) {
1466 let owned: Vec<(u64, u64, String)> = per_account
1467 .into_iter()
1468 .map(|(account, id, name)| (account, id, name.to_string()))
1469 .collect();
1470 Mock::given(method("GET"))
1471 .and(path("/accounts/cloud-api/cloudApiKeys"))
1472 .respond_with(move |_: &wiremock::Request| {
1473 let here = account_of(&cell);
1474 let keys: Vec<_> = owned
1475 .iter()
1476 .filter(|(account, _, _)| *account == here)
1477 .map(|(_, id, name)| serde_json::json!({"id": id, "name": name}))
1478 .collect();
1479 ResponseTemplate::new(200).set_body_json(serde_json::json!({"cloudApiKeys": keys}))
1480 })
1481 .mount(server)
1482 .await;
1483 }
1484
1485 #[tokio::test]
1489 async fn revoking_by_name_points_the_session_at_the_key_account() {
1490 let server = MockServer::start().await;
1491 let cell = mock_session(&server, 111, vec![222]).await;
1492 mock_keys(
1493 &server,
1494 cell,
1495 vec![
1496 (111, 9, "a-key-on-the-default-account"),
1497 (222, 5, "redisctl-cli-on-222"),
1498 ],
1499 )
1500 .await;
1501 Mock::given(method("DELETE"))
1502 .and(path("/accounts/cloud-api/cloudApiKeys/5"))
1503 .respond_with(ResponseTemplate::new(200))
1504 .expect(1)
1505 .mount(&server)
1506 .await;
1507
1508 assert!(
1509 authenticator(&server)
1510 .revoke_capi_key(&tokens(), Some(222), "redisctl-cli-on-222")
1511 .await
1512 .unwrap(),
1513 "the key on the recorded account should be found and deleted"
1514 );
1515 }
1516
1517 #[tokio::test]
1521 async fn revoking_by_name_refuses_when_the_switch_does_not_take() {
1522 let server = MockServer::start().await;
1523 let cell = mock_session(&server, 111, vec![]).await;
1524 mock_keys(&server, cell, vec![(111, 9, "redisctl-cli-shared-name")]).await;
1525 Mock::given(method("DELETE"))
1526 .and(wiremock::matchers::path_regex(
1527 r"^/accounts/cloud-api/cloudApiKeys/\d+$",
1528 ))
1529 .respond_with(ResponseTemplate::new(200))
1530 .expect(0)
1531 .named("nothing may be deleted from an account we did not reach")
1532 .mount(&server)
1533 .await;
1534
1535 let err = authenticator(&server)
1536 .revoke_capi_key(&tokens(), Some(222), "redisctl-cli-shared-name")
1537 .await
1538 .expect_err("an unverified switch must not be treated as success");
1539 assert!(
1540 format!("{err}").contains("still reports"),
1541 "the error should say the session did not move, got: {err}"
1542 );
1543 }
1544
1545 #[tokio::test]
1549 async fn revoking_across_accounts_refuses_when_the_switch_back_does_not_take() {
1550 let server = MockServer::start().await;
1551 let cell = mock_session(&server, 111, vec![222]).await;
1552 mock_keys(
1553 &server,
1554 cell,
1555 vec![(111, 9, "redisctl-cli-1"), (222, 7, "redisctl-cli-1")],
1556 )
1557 .await;
1558 Mock::given(method("GET"))
1559 .and(path("/accounts"))
1560 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1561 "accounts": [
1562 {"id": 111, "name": "One", "api_access_key": "KEY-111"},
1563 {"id": 222, "name": "Two", "api_access_key": "KEY-222"}
1564 ]
1565 })))
1566 .mount(&server)
1567 .await;
1568 Mock::given(method("DELETE"))
1571 .and(wiremock::matchers::path_regex(
1572 r"^/accounts/cloud-api/cloudApiKeys/\d+$",
1573 ))
1574 .respond_with(ResponseTemplate::new(200))
1575 .expect(0)
1576 .named("nothing may be deleted from an account we did not reach")
1577 .mount(&server)
1578 .await;
1579
1580 let (_, revoker) = authenticator(&server)
1581 .complete_login_with_mfa(
1582 &tokens(),
1583 "redisctl-cli-2",
1584 LoginFlow::Switch,
1585 AccountChoice::Id(222),
1586 Some(SupersededKey {
1587 account_id: 111,
1588 key_name: "redisctl-cli-1".to_string(),
1589 }),
1590 |_, _| Ok(None),
1591 )
1592 .await
1593 .unwrap();
1594 assert!(
1595 !revoker.unwrap().revoke().await,
1596 "a switch that did not land must not be reported as a revocation"
1597 );
1598 }
1599
1600 #[tokio::test]
1604 async fn revocation_targets_the_recorded_key_alone() {
1605 let server = MockServer::start().await;
1606 let cell = mock_session(&server, 111, vec![]).await;
1607 mock_keys(
1608 &server,
1609 cell,
1610 vec![
1611 (111, 1, "redisctl-cli-0"),
1612 (111, 2, "redisctl-cli-11"),
1613 (111, 3, "redisctl-cli-1"),
1614 (111, 4, "someone-elses-key"),
1615 ],
1616 )
1617 .await;
1618 Mock::given(method("DELETE"))
1620 .and(path("/accounts/cloud-api/cloudApiKeys/3"))
1621 .respond_with(ResponseTemplate::new(200))
1622 .expect(1)
1623 .named("delete the recorded key, by exact name")
1624 .mount(&server)
1625 .await;
1626
1627 assert!(
1628 authenticator(&server)
1629 .revoke_capi_key(&tokens(), None, "redisctl-cli-1")
1630 .await
1631 .unwrap()
1632 );
1633 }
1634
1635 #[tokio::test]
1638 async fn revocation_deletes_nothing_when_the_recorded_key_is_gone() {
1639 let server = MockServer::start().await;
1640 let cell = mock_session(&server, 111, vec![]).await;
1641 mock_keys(&server, cell, vec![(111, 9, "a-key-someone-else-minted")]).await;
1642 Mock::given(method("DELETE"))
1643 .and(wiremock::matchers::path_regex(
1644 r"^/accounts/cloud-api/cloudApiKeys/\d+$",
1645 ))
1646 .respond_with(ResponseTemplate::new(200))
1647 .expect(0)
1648 .named("nothing may be deleted when the recorded key is absent")
1649 .mount(&server)
1650 .await;
1651
1652 assert!(
1653 !authenticator(&server)
1654 .revoke_capi_key(&tokens(), None, "redisctl-cli-1")
1655 .await
1656 .unwrap(),
1657 "a key that is not there cannot be reported as revoked"
1658 );
1659 }
1660
1661 #[tokio::test]
1664 async fn revoking_by_name_without_an_account_does_not_switch() {
1665 let server = MockServer::start().await;
1666 common_login_mocks(&server).await;
1667 Mock::given(method("GET"))
1668 .and(path("/accounts/cloud-api/cloudApiKeys"))
1669 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1670 "cloudApiKeys": [{"id": 3, "name": "redisctl-cli-somewhere"}]
1671 })))
1672 .mount(&server)
1673 .await;
1674 Mock::given(method("DELETE"))
1675 .and(path("/accounts/cloud-api/cloudApiKeys/3"))
1676 .respond_with(ResponseTemplate::new(200))
1677 .expect(1)
1678 .mount(&server)
1679 .await;
1680
1681 assert!(
1682 authenticator(&server)
1683 .revoke_capi_key(&tokens(), None, "redisctl-cli-somewhere")
1684 .await
1685 .unwrap()
1686 );
1687 assert!(
1688 !server
1689 .received_requests()
1690 .await
1691 .unwrap()
1692 .iter()
1693 .any(|r| r.url.path().starts_with("/accounts/setcurrent/")),
1694 "no account was recorded, so there is nothing to switch to"
1695 );
1696 }
1697
1698 #[tokio::test]
1699 async fn complete_login_errors_when_login_rejected() {
1700 let server = MockServer::start().await;
1701 Mock::given(method("POST"))
1702 .and(path("/login"))
1703 .respond_with(ResponseTemplate::new(401).append_header("Set-Cookie", "JSESSIONID=S"))
1704 .mount(&server)
1705 .await;
1706 let auth = CloudAuthenticator::new(
1707 Url::parse("https://issuer.example/oauth2/default").unwrap(),
1708 "cid",
1709 Url::parse(&server.uri()).unwrap(),
1710 "https://capi.example/v1",
1711 );
1712 let tokens = TokenSet {
1713 access_token: "AT".into(),
1714 refresh_token: None,
1715 expires_in: 3600,
1716 };
1717 assert!(matches!(
1718 auth.complete_login(&tokens, "k", LoginFlow::Loopback, AccountChoice::Current)
1719 .await,
1720 Err(AuthError::Protocol(_))
1721 ));
1722 }
1723}