1use serde::Deserialize;
17use url::Url;
18
19use super::{AuthError, default_http_client, endpoint, truncate};
20
21const INSUFFICIENT_PERMISSION_CODE: &str = "insufficient-permission";
25const FORBIDDEN_REQUEST_CODE: &str = "forbidden-request";
29const SOCIAL_MIGRATION_REQUIRED_CODE: &str = "user-agreement-for-social-login-migration-missing";
32const MFA_REQUIRED_CODE: &str = "user-mfa-required";
34const MFA_INVALID_CODE: &str = "mfa-invalid-code";
36const MFA_INVALID_TYPE_CODE: &str = "mfa-invalid-type";
38const MFA_QUOTA_EXCEEDED_CODE: &str = "mfa-quota-exceeded";
40const MFA_TYPE_TOTP: &str = "Totp";
46
47const UTM_SOURCE: &str = "redisctl";
51const UTM_MEDIUM: &str = "cli";
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum LoginFlow {
58 Loopback,
60 Device,
62 Switch,
64}
65
66impl LoginFlow {
67 fn as_str(self) -> &'static str {
68 match self {
69 Self::Loopback => "loopback",
70 Self::Device => "device",
71 Self::Switch => "switch",
72 }
73 }
74}
75
76pub struct SmApiClient {
78 base_url: Url,
79 http: reqwest::Client,
80 session: Option<Session>,
81 flow: LoginFlow,
83 pending_mfa_cookie: Option<String>,
87}
88
89struct Session {
90 cookie: String,
92 csrf: String,
93}
94
95#[derive(Debug, Default, Deserialize)]
97struct SmErrorEnvelope {
98 errors: Option<SmError>,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct SmError {
103 code: Option<String>,
104 params: Option<serde_json::Value>,
107}
108
109fn sm_error_code(body: &str) -> Option<(String, Option<serde_json::Value>)> {
111 let env: SmErrorEnvelope = serde_json::from_str(body).ok()?;
112 let err = env.errors?;
113 Some((err.code?, err.params))
114}
115
116fn mfa_factors(params: Option<&serde_json::Value>) -> Vec<String> {
119 fn strings(v: &serde_json::Value, out: &mut Vec<String>) {
120 match v {
121 serde_json::Value::String(s) => {
122 if let Ok(inner) = serde_json::from_str::<serde_json::Value>(s) {
124 strings(&inner, out);
125 } else if !s.is_empty() {
126 out.push(s.clone());
127 }
128 }
129 serde_json::Value::Array(items) => items.iter().for_each(|i| strings(i, out)),
130 serde_json::Value::Object(map) => {
131 for key in ["type", "factorType", "mfaType"] {
132 if let Some(serde_json::Value::String(s)) = map.get(key) {
133 out.push(s.clone());
134 return;
135 }
136 }
137 map.values().for_each(|v| strings(v, out));
138 }
139 _ => {}
140 }
141 }
142 let mut out = Vec::new();
143 if let Some(p) = params {
144 strings(p, &mut out);
145 }
146 out.sort();
147 out.dedup();
148 out
149}
150
151#[derive(Debug, Clone, Deserialize)]
153pub struct SmUser {
154 pub id: String,
156 #[serde(default)]
157 pub current_account_id: Option<String>,
158 #[serde(default)]
159 pub email: Option<String>,
160 #[serde(default)]
161 pub product_type: Option<String>,
162}
163
164impl SmUser {
165 pub fn user_account(&self) -> Result<u64, AuthError> {
167 self.id.parse().map_err(|_| {
168 AuthError::Protocol(format!("unexpected non-numeric user id {:?}", self.id))
169 })
170 }
171}
172
173#[derive(Debug, Clone, Deserialize)]
175pub struct SmAccount {
176 pub id: u64,
177 #[serde(default)]
178 pub name: Option<String>,
179 #[serde(default)]
181 pub api_access_key: Option<String>,
182}
183
184#[derive(Clone)]
186pub struct CapiKey {
187 pub name: String,
188 pub secret_key: String,
189}
190
191impl std::fmt::Debug for CapiKey {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 f.debug_struct("CapiKey")
194 .field("name", &self.name)
195 .field("secret_key", &"<redacted>")
196 .finish()
197 }
198}
199
200#[derive(Deserialize)]
201struct CsrfEnvelope {
202 #[serde(rename = "csrfToken")]
203 token: CsrfToken,
204}
205
206#[derive(Deserialize)]
207struct CsrfToken {
208 csrf_token: String,
209}
210
211#[derive(Deserialize)]
212struct AccountsEnvelope {
213 #[serde(default)]
214 accounts: Vec<SmAccount>,
215}
216
217fn sm_error_codes(body: &str) -> Vec<String> {
223 fn codes(errors: &serde_json::Value) -> Vec<String> {
224 errors
225 .as_array()
226 .map(|items| {
227 items
228 .iter()
229 .filter_map(|e| {
230 e.get("error_code")
231 .or_else(|| e.get("code"))?
232 .as_str()
233 .map(str::to_string)
234 })
235 .collect()
236 })
237 .unwrap_or_default()
238 }
239 serde_json::from_str::<serde_json::Value>(body)
240 .ok()
241 .and_then(|v| {
242 let errors = v.get("errors")?.clone();
243 Some(match errors {
244 serde_json::Value::String(inner) => {
245 codes(&serde_json::from_str::<serde_json::Value>(&inner).ok()?)
246 }
247 other => codes(&other),
248 })
249 })
250 .unwrap_or_default()
251}
252
253fn allowed_roles(body: &str) -> String {
261 fn from_params(errors: &serde_json::Value) -> Option<Vec<String>> {
262 for err in errors.as_array()? {
263 for param in err.get("params")?.as_array()? {
264 if param.get("key")?.as_str()? != "allowed-roles" {
265 continue;
266 }
267 let roles: Vec<String> = match param.get("value")? {
268 serde_json::Value::Array(items) => items
269 .iter()
270 .filter_map(|v| v.as_str())
271 .map(str::to_string)
272 .collect(),
273 serde_json::Value::String(one) => vec![one.clone()],
274 _ => continue,
275 };
276 if !roles.is_empty() {
277 return Some(roles);
278 }
279 }
280 }
281 None
282 }
283
284 let roles = serde_json::from_str::<serde_json::Value>(body)
285 .ok()
286 .and_then(|v| {
287 let errors = v.get("errors")?.clone();
288 match errors {
290 serde_json::Value::String(inner) => {
291 from_params(&serde_json::from_str::<serde_json::Value>(&inner).ok()?)
292 }
293 other => from_params(&other),
294 }
295 })
296 .unwrap_or_default();
297 match roles.len() {
298 0 => "the owner role".to_string(),
301 1 => format!("the {} role", roles[0]),
302 _ => format!("one of these roles: {}", roles.join(", ")),
303 }
304}
305
306impl SmApiClient {
307 pub fn new(base_url: Url, flow: LoginFlow) -> Self {
309 Self {
310 base_url,
311 http: default_http_client(),
312 session: None,
313 flow,
314 pending_mfa_cookie: None,
315 }
316 }
317
318 pub fn with_http_client(base_url: Url, http: reqwest::Client, flow: LoginFlow) -> Self {
320 Self {
321 base_url,
322 http,
323 session: None,
324 flow,
325 pending_mfa_cookie: None,
326 }
327 }
328
329 pub async fn login(
335 &mut self,
336 access_token: &str,
337 sm_id_token: Option<&str>,
338 ) -> Result<(), AuthError> {
339 self.post_login(access_token, sm_id_token, None, None).await
340 }
341
342 pub async fn complete_mfa(
348 &mut self,
349 access_token: &str,
350 sm_id_token: Option<&str>,
351 code: &str,
352 ) -> Result<(), AuthError> {
353 let cookie = self.pending_mfa_cookie.clone().ok_or_else(|| {
354 AuthError::Protocol("no outstanding SM multi-factor challenge to complete".into())
355 })?;
356 self.post_login(access_token, sm_id_token, Some(code), Some(&cookie))
357 .await
358 }
359
360 async fn post_login(
361 &mut self,
362 access_token: &str,
363 sm_id_token: Option<&str>,
364 mfa_code: Option<&str>,
365 mfa_cookie: Option<&str>,
366 ) -> Result<(), AuthError> {
367 let mut body = serde_json::json!({
368 "utm_source": UTM_SOURCE,
369 "utm_medium": UTM_MEDIUM,
370 "utm_campaign": self.flow.as_str(),
371 });
372 if let Some(code) = mfa_code {
373 body["mfa_type"] = MFA_TYPE_TOTP.into();
374 body["mfa_code"] = code.into();
375 }
376 let mut req = self
377 .http
378 .post(endpoint(&self.base_url, "login"))
379 .header(
380 reqwest::header::AUTHORIZATION,
381 format!("Bearer {access_token}"),
382 )
383 .header(reqwest::header::CONTENT_TYPE, "application/json")
384 .body(body.to_string());
385 if let Some(id) = sm_id_token {
386 req = req.header("sm-id-token", id);
387 }
388 if let Some(c) = mfa_cookie {
389 req = req.header(reqwest::header::COOKIE, format!("JSESSIONID={c}"));
390 }
391 let resp = req.send().await?;
392 let status = resp.status();
393 let cookie = extract_jsessionid(&resp);
396 if !status.is_success() {
397 let body = resp.text().await.unwrap_or_default();
398 return Err(self.classify_login_error(status, &body, cookie, mfa_cookie));
399 }
400 let cookie = cookie
401 .or_else(|| mfa_cookie.map(str::to_string))
402 .ok_or_else(|| AuthError::Protocol("SM /login did not set a JSESSIONID".into()))?;
403 let csrf = self.fetch_csrf(&cookie).await?;
404 self.session = Some(Session {
405 cookie: format!("JSESSIONID={cookie}"),
406 csrf,
407 });
408 self.pending_mfa_cookie = None;
409 Ok(())
410 }
411
412 fn classify_login_error(
413 &mut self,
414 status: reqwest::StatusCode,
415 body: &str,
416 cookie: Option<String>,
417 previous_cookie: Option<&str>,
418 ) -> AuthError {
419 match sm_error_code(body) {
420 Some((code, params)) if code == MFA_REQUIRED_CODE => {
421 self.pending_mfa_cookie = cookie.or_else(|| previous_cookie.map(str::to_string));
423 AuthError::MfaRequired {
424 factors: mfa_factors(params.as_ref()),
425 }
426 }
427 Some((code, _)) if code == SOCIAL_MIGRATION_REQUIRED_CODE => {
428 AuthError::MigrationRequired
429 }
430 Some((code, _)) if code == MFA_INVALID_CODE => AuthError::MfaInvalidCode,
431 Some((code, _)) if code == MFA_INVALID_TYPE_CODE => AuthError::Protocol(
434 "the multi-factor type this client sent was rejected by Redis Cloud \
435 (mfa-invalid-type); this is a bug in redisctl, please report it"
436 .to_string(),
437 ),
438 Some((code, _)) if code == MFA_QUOTA_EXCEEDED_CODE => AuthError::MfaQuotaExceeded,
439 _ => AuthError::Protocol(format!("SM /login failed ({status}): {}", truncate(body))),
440 }
441 }
442
443 async fn fetch_csrf(&self, jsessionid: &str) -> Result<String, AuthError> {
444 let body = self
445 .http
446 .get(endpoint(&self.base_url, "csrf"))
447 .header(reqwest::header::COOKIE, format!("JSESSIONID={jsessionid}"))
448 .send()
449 .await?
450 .text()
451 .await?;
452 let env: CsrfEnvelope = serde_json::from_str(&body)
453 .map_err(|e| AuthError::Protocol(format!("could not parse /csrf response: {e}")))?;
454 Ok(env.token.csrf_token)
455 }
456
457 pub async fn fetch_current_user(&self) -> Result<SmUser, AuthError> {
459 let body = self.authed_get("users/me").await?.text().await?;
460 serde_json::from_str(&body)
461 .map_err(|e| AuthError::Protocol(format!("could not parse /users/me: {e}")))
462 }
463
464 pub async fn fetch_accounts(&self) -> Result<Vec<SmAccount>, AuthError> {
466 let body = self.authed_get("accounts").await?.text().await?;
467 let env: AccountsEnvelope = serde_json::from_str(&body)
468 .map_err(|e| AuthError::Protocol(format!("could not parse /accounts: {e}")))?;
469 Ok(env.accounts)
470 }
471
472 pub async fn ensure_capi_enabled(&self) -> Result<bool, AuthError> {
477 let resp = self
478 .authed_post_json(
479 "accounts/cloud-api/cloudApiAccessKey",
480 serde_json::json!({}),
481 )
482 .await?;
483 if resp.status().is_success() {
484 return Ok(true);
485 }
486 let status = resp.status();
487 let body = resp.text().await.unwrap_or_default();
488 if body.contains("account_api_key_already_exists") {
489 return Ok(false);
490 }
491 if body.contains(INSUFFICIENT_PERMISSION_CODE) {
494 return Err(AuthError::NotAccountOwner {
495 allowed_roles: allowed_roles(&body),
496 });
497 }
498 if status == reqwest::StatusCode::FORBIDDEN
499 && sm_error_codes(&body) == [FORBIDDEN_REQUEST_CODE]
500 {
501 return Err(AuthError::CapiDisabled);
502 }
503 Err(AuthError::Protocol(format!(
504 "enabling CAPI failed ({status}): {}",
505 truncate(&body)
506 )))
507 }
508
509 pub async fn mint_capi_key(&self, name: &str, user_account: u64) -> Result<CapiKey, AuthError> {
511 let body = self
512 .authed_post_json(
513 "accounts/cloud-api/cloudApiKeys",
514 serde_json::json!({
515 "cloudApiKey": { "name": name, "user_account": user_account, "ip_whitelist": [] }
516 }),
517 )
518 .await?
519 .text()
520 .await?;
521 let value: serde_json::Value = serde_json::from_str(&body)
522 .map_err(|e| AuthError::Protocol(format!("could not parse mint response: {e}")))?;
523 let obj = value.get("cloudApiKey").unwrap_or(&value);
525 let secret_key = obj
526 .get("secret_key")
527 .and_then(|v| v.as_str())
528 .ok_or_else(|| AuthError::Protocol("mint response missing secret_key".into()))?
529 .to_string();
530 let key_name = obj
531 .get("name")
532 .and_then(|v| v.as_str())
533 .unwrap_or(name)
534 .to_string();
535 Ok(CapiKey {
536 name: key_name,
537 secret_key,
538 })
539 }
540
541 pub async fn fetch_capi_keys(&self) -> Result<Vec<String>, AuthError> {
544 let body = self
545 .authed_get("accounts/cloud-api/cloudApiKeys")
546 .await?
547 .text()
548 .await?;
549 let value: serde_json::Value = serde_json::from_str(&body)
550 .map_err(|e| AuthError::Protocol(format!("could not parse cloudApiKeys list: {e}")))?;
551 let arr = value
553 .get("cloudApiKeys")
554 .and_then(|v| v.as_array())
555 .or_else(|| value.as_array())
556 .cloned()
557 .unwrap_or_default();
558 Ok(arr
559 .iter()
560 .filter_map(|k| {
561 let obj = k.get("cloudApiKey").unwrap_or(k);
562 obj.get("name").and_then(|v| v.as_str()).map(String::from)
563 })
564 .collect())
565 }
566
567 pub async fn fetch_capi_key_entries(&self) -> Result<Vec<(u64, String)>, AuthError> {
569 let body = self
570 .authed_get("accounts/cloud-api/cloudApiKeys")
571 .await?
572 .text()
573 .await?;
574 let value: serde_json::Value = serde_json::from_str(&body)
575 .map_err(|e| AuthError::Protocol(format!("could not parse cloudApiKeys list: {e}")))?;
576 let arr = value
577 .get("cloudApiKeys")
578 .and_then(|v| v.as_array())
579 .or_else(|| value.as_array())
580 .cloned()
581 .unwrap_or_default();
582 Ok(arr
583 .iter()
584 .filter_map(|k| {
585 let obj = k.get("cloudApiKey").unwrap_or(k);
586 let id = obj.get("id").and_then(|v| v.as_u64())?;
587 let name = obj.get("name").and_then(|v| v.as_str())?.to_string();
588 Some((id, name))
589 })
590 .collect())
591 }
592
593 pub async fn delete_capi_key(&self, id: u64) -> Result<(), AuthError> {
595 let s = self.session()?;
596 let resp = self
597 .http
598 .delete(endpoint(
599 &self.base_url,
600 &format!("accounts/cloud-api/cloudApiKeys/{id}"),
601 ))
602 .header(reqwest::header::COOKIE, &s.cookie)
603 .header("x-csrf-token", &s.csrf)
604 .send()
605 .await?;
606 if resp.status().is_success() {
607 return Ok(());
608 }
609 let status = resp.status();
610 let body = resp.text().await.unwrap_or_default();
611 Err(AuthError::Protocol(format!(
612 "could not revoke API key {id} ({status}): {}",
613 truncate(&body)
614 )))
615 }
616
617 fn session(&self) -> Result<&Session, AuthError> {
618 self.session
619 .as_ref()
620 .ok_or_else(|| AuthError::Protocol("not logged in to the SM API".into()))
621 }
622
623 pub async fn set_current_account(&self, account_id: u64) -> Result<(), AuthError> {
630 let resp = self
631 .authed_post_json(
632 &format!("accounts/setcurrent/{account_id}"),
633 serde_json::json!({}),
634 )
635 .await?;
636 if resp.status().is_success() {
637 return Ok(());
638 }
639 let status = resp.status();
640 let body = resp.text().await.unwrap_or_default();
641 Err(AuthError::Protocol(format!(
642 "could not switch to account {account_id} ({status}): {}",
643 truncate(&body)
644 )))
645 }
646
647 async fn authed_get(&self, path: &str) -> Result<reqwest::Response, AuthError> {
648 let s = self.session()?;
649 Ok(self
650 .http
651 .get(endpoint(&self.base_url, path))
652 .header(reqwest::header::COOKIE, &s.cookie)
653 .header("x-csrf-token", &s.csrf)
654 .send()
655 .await?)
656 }
657
658 async fn authed_post_json(
659 &self,
660 path: &str,
661 body: serde_json::Value,
662 ) -> Result<reqwest::Response, AuthError> {
663 let s = self.session()?;
664 Ok(self
665 .http
666 .post(endpoint(&self.base_url, path))
667 .header(reqwest::header::COOKIE, &s.cookie)
668 .header("x-csrf-token", &s.csrf)
669 .header(reqwest::header::CONTENT_TYPE, "application/json")
670 .body(body.to_string())
671 .send()
672 .await?)
673 }
674}
675
676fn extract_jsessionid(resp: &reqwest::Response) -> Option<String> {
678 for value in resp.headers().get_all(reqwest::header::SET_COOKIE) {
679 let Ok(text) = value.to_str() else { continue };
680 for part in text.split(';') {
681 if let Some(v) = part.trim().strip_prefix("JSESSIONID=") {
682 return Some(v.to_string());
683 }
684 }
685 }
686 None
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use wiremock::matchers::{body_string_contains, header, method, path};
693 use wiremock::{Mock, MockServer, ResponseTemplate};
694
695 async fn mount_login_and_csrf(server: &MockServer) {
696 Mock::given(method("POST"))
697 .and(path("/login"))
698 .respond_with(
699 ResponseTemplate::new(200)
700 .append_header("Set-Cookie", "JSESSIONID=SID123; Path=/; HttpOnly"),
701 )
702 .mount(server)
703 .await;
704 Mock::given(method("GET"))
705 .and(path("/csrf"))
706 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
707 "csrfToken": { "csrf_token": "CSRF-XYZ", "csrf_enabled": true, "errors": [] }
708 })))
709 .mount(server)
710 .await;
711 }
712
713 fn client(server: &MockServer) -> SmApiClient {
714 SmApiClient::new(Url::parse(&server.uri()).unwrap(), LoginFlow::Loopback)
715 }
716
717 async fn logged_in(server: &MockServer) -> SmApiClient {
718 mount_login_and_csrf(server).await;
719 let mut c = client(server);
720 c.login("ACCESS", None).await.unwrap();
721 c
722 }
723
724 #[tokio::test]
725 async fn login_then_users_me_sends_cookie_and_csrf() {
726 let server = MockServer::start().await;
727 Mock::given(method("GET"))
729 .and(path("/users/me"))
730 .and(header("cookie", "JSESSIONID=SID123"))
731 .and(header("x-csrf-token", "CSRF-XYZ"))
732 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
733 "id": "114429",
734 "current_account_id": "112117",
735 "email": "user@example.com",
736 "product_type": "unifiedrc"
737 })))
738 .mount(&server)
739 .await;
740
741 let c = logged_in(&server).await;
742 let user = c.fetch_current_user().await.unwrap();
743 assert_eq!(user.id, "114429");
744 assert_eq!(user.user_account().unwrap(), 114429);
745 assert_eq!(user.current_account_id.as_deref(), Some("112117"));
746 assert_eq!(user.email.as_deref(), Some("user@example.com"));
747 }
748
749 #[tokio::test]
750 async fn accounts_extracts_api_access_key() {
751 let server = MockServer::start().await;
752 Mock::given(method("GET"))
753 .and(path("/accounts"))
754 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
755 "accounts": [
756 { "id": 112117, "name": "Krum", "api_access_key": "ACCT-KEY", "has_paid": false }
757 ]
758 })))
759 .mount(&server)
760 .await;
761
762 let c = logged_in(&server).await;
763 let accounts = c.fetch_accounts().await.unwrap();
764 assert_eq!(accounts.len(), 1);
765 assert_eq!(accounts[0].id, 112117);
766 assert_eq!(accounts[0].api_access_key.as_deref(), Some("ACCT-KEY"));
767 }
768
769 #[tokio::test]
770 async fn ensure_capi_enabled_ok_on_success() {
771 let server = MockServer::start().await;
772 Mock::given(method("POST"))
773 .and(path("/accounts/cloud-api/cloudApiAccessKey"))
774 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
775 "cloudApiAccessKey": { "accessKey": "ACCT-KEY" }
776 })))
777 .mount(&server)
778 .await;
779 let c = logged_in(&server).await;
780 assert!(c.ensure_capi_enabled().await.is_ok());
781 }
782
783 #[tokio::test]
784 async fn ensure_capi_enabled_ok_when_already_exists() {
785 let server = MockServer::start().await;
786 Mock::given(method("POST"))
787 .and(path("/accounts/cloud-api/cloudApiAccessKey"))
788 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
789 "errors": { "status": 400, "code": "account_api_key_already_exists", "message": "" }
790 })))
791 .mount(&server)
792 .await;
793 let c = logged_in(&server).await;
794 assert!(c.ensure_capi_enabled().await.is_ok());
795 }
796
797 #[tokio::test]
799 async fn key_entries_carry_ids_and_delete_targets_one() {
800 let server = MockServer::start().await;
801 let c = logged_in(&server).await;
802 Mock::given(method("GET"))
803 .and(path("/accounts/cloud-api/cloudApiKeys"))
804 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
805 "cloudApiKeys": [
806 {"id": 11, "name": "redisctl-one"},
807 {"id": 22, "name": "someone-elses-key"}
808 ]
809 })))
810 .mount(&server)
811 .await;
812 let entries = c.fetch_capi_key_entries().await.unwrap();
813 assert_eq!(
814 entries,
815 vec![
816 (11, "redisctl-one".to_string()),
817 (22, "someone-elses-key".to_string())
818 ]
819 );
820
821 Mock::given(method("DELETE"))
822 .and(path("/accounts/cloud-api/cloudApiKeys/11"))
823 .respond_with(ResponseTemplate::new(200))
824 .mount(&server)
825 .await;
826 assert!(c.delete_capi_key(11).await.is_ok());
827 }
828
829 #[tokio::test]
830 async fn delete_capi_key_surfaces_a_refusal() {
831 let server = MockServer::start().await;
832 let c = logged_in(&server).await;
833 Mock::given(method("DELETE"))
834 .and(path("/accounts/cloud-api/cloudApiKeys/11"))
835 .respond_with(ResponseTemplate::new(403).set_body_string("nope"))
836 .mount(&server)
837 .await;
838 assert!(c.delete_capi_key(11).await.is_err());
839 }
840 #[tokio::test]
843 async fn ensure_capi_enabled_reports_owner_only_distinctly() {
844 let server = MockServer::start().await;
845 let c = logged_in(&server).await;
846 Mock::given(method("POST"))
847 .and(path("/accounts/cloud-api/cloudApiAccessKey"))
848 .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
849 "errors": "[{\"field_name\":null,\"error_code\":\"insufficient-permission\",\"params\":[{\"key\":\"allowed-roles\",\"value\":[\"owner\"]}]}]"
850 })))
851 .mount(&server)
852 .await;
853 match c.ensure_capi_enabled().await {
855 Err(AuthError::NotAccountOwner { allowed_roles }) => {
856 assert_eq!(allowed_roles, "the owner role")
857 }
858 other => panic!("expected NotAccountOwner, got {other:?}"),
859 }
860 }
861
862 #[test]
866 fn allowed_roles_reads_the_param_regardless_of_shape() {
867 assert_eq!(
869 allowed_roles(
870 r#"{"errors":"[{\"error_code\":\"insufficient-permission\",\"params\":[{\"key\":\"allowed-roles\",\"value\":[\"owner\"]}]}]"}"#
871 ),
872 "the owner role"
873 );
874 assert_eq!(
877 allowed_roles(
878 r#"{"errors":[{"params":[{"value":["owner"],"key":"allowed-roles"},{"value":["viewer"],"key":"current-role"}]}]}"#
879 ),
880 "the owner role"
881 );
882 assert_eq!(
884 allowed_roles(
885 r#"{"errors":[{"params":[{"key":"allowed-roles","value":["owner","billing_admin","Manager"]}]}]}"#
886 ),
887 "one of these roles: owner, billing_admin, Manager"
888 );
889 assert_eq!(
891 allowed_roles(r#"{"errors":"insufficient-permission"}"#),
892 "the owner role"
893 );
894 assert_eq!(allowed_roles("not json at all"), "the owner role");
895 assert_eq!(
896 allowed_roles(r#"{"errors":[{"params":[{"key":"other","value":["x"]}]}]}"#),
897 "the owner role"
898 );
899 }
900
901 #[test]
903 fn sm_error_codes_reads_every_nested_code() {
904 assert_eq!(
905 sm_error_codes(r#"{"errors":"[{\"error_code\":\"forbidden-request\"}]"}"#),
906 vec!["forbidden-request"]
907 );
908 assert_eq!(
909 sm_error_codes(r#"{"errors":[{"error_code":"a"},{"error_code":"b"}]}"#),
910 vec!["a", "b"]
911 );
912 assert!(sm_error_codes("not json").is_empty());
913 }
914
915 #[tokio::test]
918 async fn ensure_capi_enabled_reports_a_disabled_account_distinctly() {
919 let server = MockServer::start().await;
920 let c = logged_in(&server).await;
921 Mock::given(method("POST"))
922 .and(path("/accounts/cloud-api/cloudApiAccessKey"))
923 .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
924 "errors": "[{\"field_name\":null,\"error_code\":\"forbidden-request\"}]"
925 })))
926 .mount(&server)
927 .await;
928 assert!(matches!(
929 c.ensure_capi_enabled().await,
930 Err(AuthError::CapiDisabled)
931 ));
932 }
933
934 #[tokio::test]
935 async fn ensure_capi_enabled_errors_on_other_failure() {
936 let server = MockServer::start().await;
937 Mock::given(method("POST"))
938 .and(path("/accounts/cloud-api/cloudApiAccessKey"))
939 .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
940 .mount(&server)
941 .await;
942 let c = logged_in(&server).await;
943 assert!(matches!(
944 c.ensure_capi_enabled().await,
945 Err(AuthError::Protocol(_))
946 ));
947 }
948
949 #[tokio::test]
950 async fn mint_capi_key_reads_secret_key() {
951 let server = MockServer::start().await;
952 Mock::given(method("POST"))
953 .and(path("/accounts/cloud-api/cloudApiKeys"))
954 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
955 "id": 999, "name": "redisctl-x", "secret_key": "SECRET", "user_account": 114429,
956 "ip_whitelist": [], "errors": []
957 })))
958 .mount(&server)
959 .await;
960 let c = logged_in(&server).await;
961 let key = c.mint_capi_key("redisctl-x", 114429).await.unwrap();
962 assert_eq!(key.name, "redisctl-x");
963 assert_eq!(key.secret_key, "SECRET");
964 assert!(!format!("{key:?}").contains("SECRET"));
966 }
967
968 #[tokio::test]
969 async fn mint_capi_key_reads_wrapped_secret_key() {
970 let server = MockServer::start().await;
971 Mock::given(method("POST"))
972 .and(path("/accounts/cloud-api/cloudApiKeys"))
973 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
974 "cloudApiKey": { "name": "redisctl-y", "secret_key": "SEK" }
975 })))
976 .mount(&server)
977 .await;
978 let c = logged_in(&server).await;
979 let key = c.mint_capi_key("redisctl-y", 1).await.unwrap();
980 assert_eq!(key.secret_key, "SEK");
981 }
982
983 #[tokio::test]
984 async fn mint_capi_key_missing_secret_is_error() {
985 let server = MockServer::start().await;
986 Mock::given(method("POST"))
987 .and(path("/accounts/cloud-api/cloudApiKeys"))
988 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
989 "name": "x", "errors": ["nope"]
990 })))
991 .mount(&server)
992 .await;
993 let c = logged_in(&server).await;
994 assert!(matches!(
995 c.mint_capi_key("x", 1).await,
996 Err(AuthError::Protocol(_))
997 ));
998 }
999
1000 #[tokio::test]
1001 async fn login_without_jsessionid_errors() {
1002 let server = MockServer::start().await;
1003 Mock::given(method("POST"))
1004 .and(path("/login"))
1005 .respond_with(ResponseTemplate::new(200))
1006 .mount(&server)
1007 .await;
1008 let mut c = client(&server);
1009 assert!(matches!(
1010 c.login("ACCESS", None).await,
1011 Err(AuthError::Protocol(_))
1012 ));
1013 }
1014
1015 #[tokio::test]
1016 async fn login_failure_status_errors() {
1017 let server = MockServer::start().await;
1018 Mock::given(method("POST"))
1019 .and(path("/login"))
1020 .respond_with(
1021 ResponseTemplate::new(401)
1022 .append_header("Set-Cookie", "JSESSIONID=SID; Path=/")
1023 .set_body_json(serde_json::json!({
1024 "errors": { "status": 401, "code": "user-invalid-access-token" }
1025 })),
1026 )
1027 .mount(&server)
1028 .await;
1029 let mut c = client(&server);
1030 assert!(matches!(
1031 c.login("ACCESS", None).await,
1032 Err(AuthError::Protocol(_))
1033 ));
1034 }
1035
1036 #[tokio::test]
1039 async fn set_current_account_posts_to_setcurrent() {
1040 let server = MockServer::start().await;
1041 let c = logged_in(&server).await;
1042 Mock::given(method("POST"))
1043 .and(path("/accounts/setcurrent/424242"))
1044 .respond_with(ResponseTemplate::new(200))
1045 .mount(&server)
1046 .await;
1047 c.set_current_account(424242).await.unwrap();
1048 }
1049
1050 #[tokio::test]
1052 async fn set_current_account_surfaces_a_refusal() {
1053 let server = MockServer::start().await;
1054 let c = logged_in(&server).await;
1055 Mock::given(method("POST"))
1056 .and(path("/accounts/setcurrent/1"))
1057 .respond_with(ResponseTemplate::new(403).set_body_string("nope"))
1058 .mount(&server)
1059 .await;
1060 assert!(matches!(
1061 c.set_current_account(1).await,
1062 Err(AuthError::Protocol(_))
1063 ));
1064 }
1065
1066 #[tokio::test]
1070 async fn login_sends_utm_attribution_per_flow() {
1071 for (flow, campaign) in [
1072 (LoginFlow::Loopback, "loopback"),
1073 (LoginFlow::Device, "device"),
1074 ] {
1075 let server = MockServer::start().await;
1076 Mock::given(method("POST"))
1077 .and(path("/login"))
1078 .and(body_string_contains("\"utm_source\":\"redisctl\""))
1079 .and(body_string_contains("\"utm_medium\":\"cli\""))
1080 .and(body_string_contains(format!(
1081 "\"utm_campaign\":\"{campaign}\""
1082 )))
1083 .respond_with(
1084 ResponseTemplate::new(200).append_header("Set-Cookie", "JSESSIONID=S"),
1085 )
1086 .mount(&server)
1087 .await;
1088 Mock::given(method("GET"))
1089 .and(path("/csrf"))
1090 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1091 "csrfToken": { "csrf_token": "CSRF", "csrf_enabled": true, "errors": [] }
1092 })))
1093 .mount(&server)
1094 .await;
1095 let mut c = SmApiClient::new(Url::parse(&server.uri()).unwrap(), flow);
1096 c.login("ACCESS", None).await.unwrap();
1097 }
1098 }
1099
1100 #[tokio::test]
1102 async fn mfa_retry_still_carries_utm() {
1103 let server = MockServer::start().await;
1104 Mock::given(method("POST"))
1105 .and(path("/login"))
1106 .and(body_string_contains("\"mfa_code\":\"123456\""))
1107 .and(body_string_contains("\"mfa_type\":\"Totp\""))
1109 .and(body_string_contains("\"utm_source\":\"redisctl\""))
1110 .respond_with(ResponseTemplate::new(200).append_header("Set-Cookie", "JSESSIONID=S"))
1111 .mount(&server)
1112 .await;
1113 Mock::given(method("GET"))
1114 .and(path("/csrf"))
1115 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1116 "csrfToken": { "csrf_token": "CSRF", "csrf_enabled": true, "errors": [] }
1117 })))
1118 .mount(&server)
1119 .await;
1120 Mock::given(method("POST"))
1121 .and(path("/login"))
1122 .respond_with(
1123 ResponseTemplate::new(401)
1124 .append_header("Set-Cookie", "JSESSIONID=CH; Path=/")
1125 .set_body_json(serde_json::json!({
1126 "errors": { "status": 401, "code": "user-mfa-required" }
1127 })),
1128 )
1129 .mount(&server)
1130 .await;
1131 let mut c = client(&server);
1132 assert!(matches!(
1133 c.login("ACCESS", None).await,
1134 Err(AuthError::MfaRequired { .. })
1135 ));
1136 c.complete_mfa("ACCESS", None, "123456").await.unwrap();
1137 }
1138
1139 #[tokio::test]
1142 async fn login_social_migration_required_is_classified() {
1143 let server = MockServer::start().await;
1144 Mock::given(method("POST"))
1145 .and(path("/login"))
1146 .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
1147 "errors": {
1148 "status": 422,
1149 "code": "user-agreement-for-social-login-migration-missing"
1150 }
1151 })))
1152 .mount(&server)
1153 .await;
1154 let mut c = client(&server);
1155 assert!(matches!(
1156 c.login("ACCESS", None).await,
1157 Err(AuthError::MigrationRequired)
1158 ));
1159 }
1160
1161 #[tokio::test]
1163 async fn login_reports_mfa_challenge_with_factors() {
1164 let server = MockServer::start().await;
1165 Mock::given(method("POST"))
1166 .and(path("/login"))
1167 .respond_with(
1168 ResponseTemplate::new(401)
1169 .append_header("Set-Cookie", "JSESSIONID=CHALLENGED; Path=/")
1170 .set_body_json(serde_json::json!({
1171 "errors": { "status": 401, "code": "user-mfa-required",
1172 "params": [{ "type": "totp" }] }
1173 })),
1174 )
1175 .mount(&server)
1176 .await;
1177 let mut c = client(&server);
1178 match c.login("ACCESS", None).await {
1179 Err(AuthError::MfaRequired { factors }) => assert_eq!(factors, vec!["totp"]),
1180 other => panic!("expected MfaRequired, got {other:?}"),
1181 }
1182 }
1183
1184 #[tokio::test]
1187 async fn complete_mfa_reuses_the_challenged_session() {
1188 let server = MockServer::start().await;
1189 Mock::given(method("POST"))
1190 .and(path("/login"))
1191 .and(header("cookie", "JSESSIONID=CHALLENGED"))
1192 .and(body_string_contains("\"mfa_code\":\"123456\""))
1193 .respond_with(ResponseTemplate::new(200))
1194 .mount(&server)
1195 .await;
1196 Mock::given(method("GET"))
1197 .and(path("/csrf"))
1198 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1199 "csrfToken": { "csrf_token": "CSRF", "csrf_enabled": true, "errors": [] }
1200 })))
1201 .mount(&server)
1202 .await;
1203 Mock::given(method("POST"))
1205 .and(path("/login"))
1206 .respond_with(
1207 ResponseTemplate::new(401)
1208 .append_header("Set-Cookie", "JSESSIONID=CHALLENGED; Path=/")
1209 .set_body_json(serde_json::json!({
1210 "errors": { "status": 401, "code": "user-mfa-required" }
1211 })),
1212 )
1213 .mount(&server)
1214 .await;
1215
1216 let mut c = client(&server);
1217 assert!(matches!(
1218 c.login("ACCESS", None).await,
1219 Err(AuthError::MfaRequired { .. })
1220 ));
1221 c.complete_mfa("ACCESS", None, "123456").await.unwrap();
1223 }
1224
1225 #[tokio::test]
1228 async fn complete_mfa_without_a_challenge_errors() {
1229 let server = MockServer::start().await;
1230 let mut c = client(&server);
1231 assert!(matches!(
1232 c.complete_mfa("ACCESS", None, "123456").await,
1233 Err(AuthError::Protocol(_))
1234 ));
1235 }
1236
1237 #[tokio::test]
1238 async fn mfa_error_codes_are_classified() {
1239 for (code, want_invalid) in [("mfa-invalid-code", true), ("mfa-quota-exceeded", false)] {
1240 let server = MockServer::start().await;
1241 Mock::given(method("POST"))
1242 .and(path("/login"))
1243 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1244 "errors": { "status": 400, "code": code }
1245 })))
1246 .mount(&server)
1247 .await;
1248 let mut c = client(&server);
1249 let got = c.login("ACCESS", None).await;
1250 if want_invalid {
1251 assert!(matches!(got, Err(AuthError::MfaInvalidCode)), "{code}");
1252 } else {
1253 assert!(matches!(got, Err(AuthError::MfaQuotaExceeded)), "{code}");
1254 }
1255 }
1256 }
1257
1258 #[test]
1259 fn mfa_factors_tolerates_shapes_we_have_not_seen() {
1260 assert!(mfa_factors(None).is_empty());
1261 assert!(mfa_factors(Some(&serde_json::json!({}))).is_empty());
1262 assert_eq!(
1264 mfa_factors(Some(&serde_json::json!(
1265 r#"[{"factorType":"token:software:totp"}]"#
1266 ))),
1267 vec!["token:software:totp"]
1268 );
1269 assert!(mfa_factors(Some(&serde_json::json!(7))).is_empty());
1271 }
1272
1273 #[tokio::test]
1274 async fn login_sends_sm_id_token_only_when_provided() {
1275 let server = MockServer::start().await;
1276 Mock::given(method("POST"))
1278 .and(path("/login"))
1279 .and(header("sm-id-token", "IDT"))
1280 .respond_with(ResponseTemplate::new(200).append_header("Set-Cookie", "JSESSIONID=S"))
1281 .mount(&server)
1282 .await;
1283 Mock::given(method("GET"))
1284 .and(path("/csrf"))
1285 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1286 "csrfToken": { "csrf_token": "C" }
1287 })))
1288 .mount(&server)
1289 .await;
1290 let mut c = client(&server);
1291 assert!(c.login("ACCESS", Some("IDT")).await.is_ok());
1293 }
1294}