use serde::Deserialize;
use url::Url;
use super::{AuthError, default_http_client, endpoint, truncate};
const INSUFFICIENT_PERMISSION_CODE: &str = "insufficient-permission";
const FORBIDDEN_REQUEST_CODE: &str = "forbidden-request";
const SOCIAL_MIGRATION_REQUIRED_CODE: &str = "user-agreement-for-social-login-migration-missing";
const MFA_REQUIRED_CODE: &str = "user-mfa-required";
const MFA_INVALID_CODE: &str = "mfa-invalid-code";
const MFA_INVALID_TYPE_CODE: &str = "mfa-invalid-type";
const MFA_QUOTA_EXCEEDED_CODE: &str = "mfa-quota-exceeded";
const MFA_TYPE_TOTP: &str = "Totp";
const UTM_SOURCE: &str = "redisctl";
const UTM_MEDIUM: &str = "cli";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginFlow {
Loopback,
Device,
Switch,
}
impl LoginFlow {
fn as_str(self) -> &'static str {
match self {
Self::Loopback => "loopback",
Self::Device => "device",
Self::Switch => "switch",
}
}
}
pub struct SmApiClient {
base_url: Url,
http: reqwest::Client,
session: Option<Session>,
flow: LoginFlow,
pending_mfa_cookie: Option<String>,
}
struct Session {
cookie: String,
csrf: String,
}
#[derive(Debug, Default, Deserialize)]
struct SmErrorEnvelope {
errors: Option<SmError>,
}
#[derive(Debug, Default, Deserialize)]
struct SmError {
code: Option<String>,
params: Option<serde_json::Value>,
}
fn sm_error_code(body: &str) -> Option<(String, Option<serde_json::Value>)> {
let env: SmErrorEnvelope = serde_json::from_str(body).ok()?;
let err = env.errors?;
Some((err.code?, err.params))
}
fn mfa_factors(params: Option<&serde_json::Value>) -> Vec<String> {
fn strings(v: &serde_json::Value, out: &mut Vec<String>) {
match v {
serde_json::Value::String(s) => {
if let Ok(inner) = serde_json::from_str::<serde_json::Value>(s) {
strings(&inner, out);
} else if !s.is_empty() {
out.push(s.clone());
}
}
serde_json::Value::Array(items) => items.iter().for_each(|i| strings(i, out)),
serde_json::Value::Object(map) => {
for key in ["type", "factorType", "mfaType"] {
if let Some(serde_json::Value::String(s)) = map.get(key) {
out.push(s.clone());
return;
}
}
map.values().for_each(|v| strings(v, out));
}
_ => {}
}
}
let mut out = Vec::new();
if let Some(p) = params {
strings(p, &mut out);
}
out.sort();
out.dedup();
out
}
#[derive(Debug, Clone, Deserialize)]
pub struct SmUser {
pub id: String,
#[serde(default)]
pub current_account_id: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub product_type: Option<String>,
}
impl SmUser {
pub fn user_account(&self) -> Result<u64, AuthError> {
self.id.parse().map_err(|_| {
AuthError::Protocol(format!("unexpected non-numeric user id {:?}", self.id))
})
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SmAccount {
pub id: u64,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub api_access_key: Option<String>,
}
#[derive(Clone)]
pub struct CapiKey {
pub name: String,
pub secret_key: String,
}
impl std::fmt::Debug for CapiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CapiKey")
.field("name", &self.name)
.field("secret_key", &"<redacted>")
.finish()
}
}
#[derive(Deserialize)]
struct CsrfEnvelope {
#[serde(rename = "csrfToken")]
token: CsrfToken,
}
#[derive(Deserialize)]
struct CsrfToken {
csrf_token: String,
}
#[derive(Deserialize)]
struct AccountsEnvelope {
#[serde(default)]
accounts: Vec<SmAccount>,
}
fn sm_error_codes(body: &str) -> Vec<String> {
fn codes(errors: &serde_json::Value) -> Vec<String> {
errors
.as_array()
.map(|items| {
items
.iter()
.filter_map(|e| {
e.get("error_code")
.or_else(|| e.get("code"))?
.as_str()
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
let errors = v.get("errors")?.clone();
Some(match errors {
serde_json::Value::String(inner) => {
codes(&serde_json::from_str::<serde_json::Value>(&inner).ok()?)
}
other => codes(&other),
})
})
.unwrap_or_default()
}
fn allowed_roles(body: &str) -> String {
fn from_params(errors: &serde_json::Value) -> Option<Vec<String>> {
for err in errors.as_array()? {
for param in err.get("params")?.as_array()? {
if param.get("key")?.as_str()? != "allowed-roles" {
continue;
}
let roles: Vec<String> = match param.get("value")? {
serde_json::Value::Array(items) => items
.iter()
.filter_map(|v| v.as_str())
.map(str::to_string)
.collect(),
serde_json::Value::String(one) => vec![one.clone()],
_ => continue,
};
if !roles.is_empty() {
return Some(roles);
}
}
}
None
}
let roles = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
let errors = v.get("errors")?.clone();
match errors {
serde_json::Value::String(inner) => {
from_params(&serde_json::from_str::<serde_json::Value>(&inner).ok()?)
}
other => from_params(&other),
}
})
.unwrap_or_default();
match roles.len() {
0 => "the owner role".to_string(),
1 => format!("the {} role", roles[0]),
_ => format!("one of these roles: {}", roles.join(", ")),
}
}
impl SmApiClient {
pub fn new(base_url: Url, flow: LoginFlow) -> Self {
Self {
base_url,
http: default_http_client(),
session: None,
flow,
pending_mfa_cookie: None,
}
}
pub fn with_http_client(base_url: Url, http: reqwest::Client, flow: LoginFlow) -> Self {
Self {
base_url,
http,
session: None,
flow,
pending_mfa_cookie: None,
}
}
pub async fn login(
&mut self,
access_token: &str,
sm_id_token: Option<&str>,
) -> Result<(), AuthError> {
self.post_login(access_token, sm_id_token, None, None).await
}
pub async fn complete_mfa(
&mut self,
access_token: &str,
sm_id_token: Option<&str>,
code: &str,
) -> Result<(), AuthError> {
let cookie = self.pending_mfa_cookie.clone().ok_or_else(|| {
AuthError::Protocol("no outstanding SM multi-factor challenge to complete".into())
})?;
self.post_login(access_token, sm_id_token, Some(code), Some(&cookie))
.await
}
async fn post_login(
&mut self,
access_token: &str,
sm_id_token: Option<&str>,
mfa_code: Option<&str>,
mfa_cookie: Option<&str>,
) -> Result<(), AuthError> {
let mut body = serde_json::json!({
"utm_source": UTM_SOURCE,
"utm_medium": UTM_MEDIUM,
"utm_campaign": self.flow.as_str(),
});
if let Some(code) = mfa_code {
body["mfa_type"] = MFA_TYPE_TOTP.into();
body["mfa_code"] = code.into();
}
let mut req = self
.http
.post(endpoint(&self.base_url, "login"))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {access_token}"),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.to_string());
if let Some(id) = sm_id_token {
req = req.header("sm-id-token", id);
}
if let Some(c) = mfa_cookie {
req = req.header(reqwest::header::COOKIE, format!("JSESSIONID={c}"));
}
let resp = req.send().await?;
let status = resp.status();
let cookie = extract_jsessionid(&resp);
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(self.classify_login_error(status, &body, cookie, mfa_cookie));
}
let cookie = cookie
.or_else(|| mfa_cookie.map(str::to_string))
.ok_or_else(|| AuthError::Protocol("SM /login did not set a JSESSIONID".into()))?;
let csrf = self.fetch_csrf(&cookie).await?;
self.session = Some(Session {
cookie: format!("JSESSIONID={cookie}"),
csrf,
});
self.pending_mfa_cookie = None;
Ok(())
}
fn classify_login_error(
&mut self,
status: reqwest::StatusCode,
body: &str,
cookie: Option<String>,
previous_cookie: Option<&str>,
) -> AuthError {
match sm_error_code(body) {
Some((code, params)) if code == MFA_REQUIRED_CODE => {
self.pending_mfa_cookie = cookie.or_else(|| previous_cookie.map(str::to_string));
AuthError::MfaRequired {
factors: mfa_factors(params.as_ref()),
}
}
Some((code, _)) if code == SOCIAL_MIGRATION_REQUIRED_CODE => {
AuthError::MigrationRequired
}
Some((code, _)) if code == MFA_INVALID_CODE => AuthError::MfaInvalidCode,
Some((code, _)) if code == MFA_INVALID_TYPE_CODE => AuthError::Protocol(
"the multi-factor type this client sent was rejected by Redis Cloud \
(mfa-invalid-type); this is a bug in redisctl, please report it"
.to_string(),
),
Some((code, _)) if code == MFA_QUOTA_EXCEEDED_CODE => AuthError::MfaQuotaExceeded,
_ => AuthError::Protocol(format!("SM /login failed ({status}): {}", truncate(body))),
}
}
async fn fetch_csrf(&self, jsessionid: &str) -> Result<String, AuthError> {
let body = self
.http
.get(endpoint(&self.base_url, "csrf"))
.header(reqwest::header::COOKIE, format!("JSESSIONID={jsessionid}"))
.send()
.await?
.text()
.await?;
let env: CsrfEnvelope = serde_json::from_str(&body)
.map_err(|e| AuthError::Protocol(format!("could not parse /csrf response: {e}")))?;
Ok(env.token.csrf_token)
}
pub async fn fetch_current_user(&self) -> Result<SmUser, AuthError> {
let body = self.authed_get("users/me").await?.text().await?;
serde_json::from_str(&body)
.map_err(|e| AuthError::Protocol(format!("could not parse /users/me: {e}")))
}
pub async fn fetch_accounts(&self) -> Result<Vec<SmAccount>, AuthError> {
let body = self.authed_get("accounts").await?.text().await?;
let env: AccountsEnvelope = serde_json::from_str(&body)
.map_err(|e| AuthError::Protocol(format!("could not parse /accounts: {e}")))?;
Ok(env.accounts)
}
pub async fn ensure_capi_enabled(&self) -> Result<bool, AuthError> {
let resp = self
.authed_post_json(
"accounts/cloud-api/cloudApiAccessKey",
serde_json::json!({}),
)
.await?;
if resp.status().is_success() {
return Ok(true);
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if body.contains("account_api_key_already_exists") {
return Ok(false);
}
if body.contains(INSUFFICIENT_PERMISSION_CODE) {
return Err(AuthError::NotAccountOwner {
allowed_roles: allowed_roles(&body),
});
}
if status == reqwest::StatusCode::FORBIDDEN
&& sm_error_codes(&body) == [FORBIDDEN_REQUEST_CODE]
{
return Err(AuthError::CapiDisabled);
}
Err(AuthError::Protocol(format!(
"enabling CAPI failed ({status}): {}",
truncate(&body)
)))
}
pub async fn mint_capi_key(&self, name: &str, user_account: u64) -> Result<CapiKey, AuthError> {
let body = self
.authed_post_json(
"accounts/cloud-api/cloudApiKeys",
serde_json::json!({
"cloudApiKey": { "name": name, "user_account": user_account, "ip_whitelist": [] }
}),
)
.await?
.text()
.await?;
let value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| AuthError::Protocol(format!("could not parse mint response: {e}")))?;
let obj = value.get("cloudApiKey").unwrap_or(&value);
let secret_key = obj
.get("secret_key")
.and_then(|v| v.as_str())
.ok_or_else(|| AuthError::Protocol("mint response missing secret_key".into()))?
.to_string();
let key_name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(name)
.to_string();
Ok(CapiKey {
name: key_name,
secret_key,
})
}
pub async fn fetch_capi_keys(&self) -> Result<Vec<String>, AuthError> {
let body = self
.authed_get("accounts/cloud-api/cloudApiKeys")
.await?
.text()
.await?;
let value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| AuthError::Protocol(format!("could not parse cloudApiKeys list: {e}")))?;
let arr = value
.get("cloudApiKeys")
.and_then(|v| v.as_array())
.or_else(|| value.as_array())
.cloned()
.unwrap_or_default();
Ok(arr
.iter()
.filter_map(|k| {
let obj = k.get("cloudApiKey").unwrap_or(k);
obj.get("name").and_then(|v| v.as_str()).map(String::from)
})
.collect())
}
pub async fn fetch_capi_key_entries(&self) -> Result<Vec<(u64, String)>, AuthError> {
let body = self
.authed_get("accounts/cloud-api/cloudApiKeys")
.await?
.text()
.await?;
let value: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| AuthError::Protocol(format!("could not parse cloudApiKeys list: {e}")))?;
let arr = value
.get("cloudApiKeys")
.and_then(|v| v.as_array())
.or_else(|| value.as_array())
.cloned()
.unwrap_or_default();
Ok(arr
.iter()
.filter_map(|k| {
let obj = k.get("cloudApiKey").unwrap_or(k);
let id = obj.get("id").and_then(|v| v.as_u64())?;
let name = obj.get("name").and_then(|v| v.as_str())?.to_string();
Some((id, name))
})
.collect())
}
pub async fn delete_capi_key(&self, id: u64) -> Result<(), AuthError> {
let s = self.session()?;
let resp = self
.http
.delete(endpoint(
&self.base_url,
&format!("accounts/cloud-api/cloudApiKeys/{id}"),
))
.header(reqwest::header::COOKIE, &s.cookie)
.header("x-csrf-token", &s.csrf)
.send()
.await?;
if resp.status().is_success() {
return Ok(());
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Err(AuthError::Protocol(format!(
"could not revoke API key {id} ({status}): {}",
truncate(&body)
)))
}
fn session(&self) -> Result<&Session, AuthError> {
self.session
.as_ref()
.ok_or_else(|| AuthError::Protocol("not logged in to the SM API".into()))
}
pub async fn set_current_account(&self, account_id: u64) -> Result<(), AuthError> {
let resp = self
.authed_post_json(
&format!("accounts/setcurrent/{account_id}"),
serde_json::json!({}),
)
.await?;
if resp.status().is_success() {
return Ok(());
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Err(AuthError::Protocol(format!(
"could not switch to account {account_id} ({status}): {}",
truncate(&body)
)))
}
async fn authed_get(&self, path: &str) -> Result<reqwest::Response, AuthError> {
let s = self.session()?;
Ok(self
.http
.get(endpoint(&self.base_url, path))
.header(reqwest::header::COOKIE, &s.cookie)
.header("x-csrf-token", &s.csrf)
.send()
.await?)
}
async fn authed_post_json(
&self,
path: &str,
body: serde_json::Value,
) -> Result<reqwest::Response, AuthError> {
let s = self.session()?;
Ok(self
.http
.post(endpoint(&self.base_url, path))
.header(reqwest::header::COOKIE, &s.cookie)
.header("x-csrf-token", &s.csrf)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.to_string())
.send()
.await?)
}
}
fn extract_jsessionid(resp: &reqwest::Response) -> Option<String> {
for value in resp.headers().get_all(reqwest::header::SET_COOKIE) {
let Ok(text) = value.to_str() else { continue };
for part in text.split(';') {
if let Some(v) = part.trim().strip_prefix("JSESSIONID=") {
return Some(v.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn mount_login_and_csrf(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(200)
.append_header("Set-Cookie", "JSESSIONID=SID123; Path=/; HttpOnly"),
)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"csrfToken": { "csrf_token": "CSRF-XYZ", "csrf_enabled": true, "errors": [] }
})))
.mount(server)
.await;
}
fn client(server: &MockServer) -> SmApiClient {
SmApiClient::new(Url::parse(&server.uri()).unwrap(), LoginFlow::Loopback)
}
async fn logged_in(server: &MockServer) -> SmApiClient {
mount_login_and_csrf(server).await;
let mut c = client(server);
c.login("ACCESS", None).await.unwrap();
c
}
#[tokio::test]
async fn login_then_users_me_sends_cookie_and_csrf() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/me"))
.and(header("cookie", "JSESSIONID=SID123"))
.and(header("x-csrf-token", "CSRF-XYZ"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "114429",
"current_account_id": "112117",
"email": "user@example.com",
"product_type": "unifiedrc"
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
let user = c.fetch_current_user().await.unwrap();
assert_eq!(user.id, "114429");
assert_eq!(user.user_account().unwrap(), 114429);
assert_eq!(user.current_account_id.as_deref(), Some("112117"));
assert_eq!(user.email.as_deref(), Some("user@example.com"));
}
#[tokio::test]
async fn accounts_extracts_api_access_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/accounts"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accounts": [
{ "id": 112117, "name": "Krum", "api_access_key": "ACCT-KEY", "has_paid": false }
]
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
let accounts = c.fetch_accounts().await.unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, 112117);
assert_eq!(accounts[0].api_access_key.as_deref(), Some("ACCT-KEY"));
}
#[tokio::test]
async fn ensure_capi_enabled_ok_on_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiAccessKey"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"cloudApiAccessKey": { "accessKey": "ACCT-KEY" }
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
assert!(c.ensure_capi_enabled().await.is_ok());
}
#[tokio::test]
async fn ensure_capi_enabled_ok_when_already_exists() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiAccessKey"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"errors": { "status": 400, "code": "account_api_key_already_exists", "message": "" }
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
assert!(c.ensure_capi_enabled().await.is_ok());
}
#[tokio::test]
async fn key_entries_carry_ids_and_delete_targets_one() {
let server = MockServer::start().await;
let c = logged_in(&server).await;
Mock::given(method("GET"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"cloudApiKeys": [
{"id": 11, "name": "redisctl-one"},
{"id": 22, "name": "someone-elses-key"}
]
})))
.mount(&server)
.await;
let entries = c.fetch_capi_key_entries().await.unwrap();
assert_eq!(
entries,
vec![
(11, "redisctl-one".to_string()),
(22, "someone-elses-key".to_string())
]
);
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/11"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
assert!(c.delete_capi_key(11).await.is_ok());
}
#[tokio::test]
async fn delete_capi_key_surfaces_a_refusal() {
let server = MockServer::start().await;
let c = logged_in(&server).await;
Mock::given(method("DELETE"))
.and(path("/accounts/cloud-api/cloudApiKeys/11"))
.respond_with(ResponseTemplate::new(403).set_body_string("nope"))
.mount(&server)
.await;
assert!(c.delete_capi_key(11).await.is_err());
}
#[tokio::test]
async fn ensure_capi_enabled_reports_owner_only_distinctly() {
let server = MockServer::start().await;
let c = logged_in(&server).await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiAccessKey"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
"errors": "[{\"field_name\":null,\"error_code\":\"insufficient-permission\",\"params\":[{\"key\":\"allowed-roles\",\"value\":[\"owner\"]}]}]"
})))
.mount(&server)
.await;
match c.ensure_capi_enabled().await {
Err(AuthError::NotAccountOwner { allowed_roles }) => {
assert_eq!(allowed_roles, "the owner role")
}
other => panic!("expected NotAccountOwner, got {other:?}"),
}
}
#[test]
fn allowed_roles_reads_the_param_regardless_of_shape() {
assert_eq!(
allowed_roles(
r#"{"errors":"[{\"error_code\":\"insufficient-permission\",\"params\":[{\"key\":\"allowed-roles\",\"value\":[\"owner\"]}]}]"}"#
),
"the owner role"
);
assert_eq!(
allowed_roles(
r#"{"errors":[{"params":[{"value":["owner"],"key":"allowed-roles"},{"value":["viewer"],"key":"current-role"}]}]}"#
),
"the owner role"
);
assert_eq!(
allowed_roles(
r#"{"errors":[{"params":[{"key":"allowed-roles","value":["owner","billing_admin","Manager"]}]}]}"#
),
"one of these roles: owner, billing_admin, Manager"
);
assert_eq!(
allowed_roles(r#"{"errors":"insufficient-permission"}"#),
"the owner role"
);
assert_eq!(allowed_roles("not json at all"), "the owner role");
assert_eq!(
allowed_roles(r#"{"errors":[{"params":[{"key":"other","value":["x"]}]}]}"#),
"the owner role"
);
}
#[test]
fn sm_error_codes_reads_every_nested_code() {
assert_eq!(
sm_error_codes(r#"{"errors":"[{\"error_code\":\"forbidden-request\"}]"}"#),
vec!["forbidden-request"]
);
assert_eq!(
sm_error_codes(r#"{"errors":[{"error_code":"a"},{"error_code":"b"}]}"#),
vec!["a", "b"]
);
assert!(sm_error_codes("not json").is_empty());
}
#[tokio::test]
async fn ensure_capi_enabled_reports_a_disabled_account_distinctly() {
let server = MockServer::start().await;
let c = logged_in(&server).await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiAccessKey"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
"errors": "[{\"field_name\":null,\"error_code\":\"forbidden-request\"}]"
})))
.mount(&server)
.await;
assert!(matches!(
c.ensure_capi_enabled().await,
Err(AuthError::CapiDisabled)
));
}
#[tokio::test]
async fn ensure_capi_enabled_errors_on_other_failure() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiAccessKey"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let c = logged_in(&server).await;
assert!(matches!(
c.ensure_capi_enabled().await,
Err(AuthError::Protocol(_))
));
}
#[tokio::test]
async fn mint_capi_key_reads_secret_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 999, "name": "redisctl-x", "secret_key": "SECRET", "user_account": 114429,
"ip_whitelist": [], "errors": []
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
let key = c.mint_capi_key("redisctl-x", 114429).await.unwrap();
assert_eq!(key.name, "redisctl-x");
assert_eq!(key.secret_key, "SECRET");
assert!(!format!("{key:?}").contains("SECRET"));
}
#[tokio::test]
async fn mint_capi_key_reads_wrapped_secret_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"cloudApiKey": { "name": "redisctl-y", "secret_key": "SEK" }
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
let key = c.mint_capi_key("redisctl-y", 1).await.unwrap();
assert_eq!(key.secret_key, "SEK");
}
#[tokio::test]
async fn mint_capi_key_missing_secret_is_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/accounts/cloud-api/cloudApiKeys"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "x", "errors": ["nope"]
})))
.mount(&server)
.await;
let c = logged_in(&server).await;
assert!(matches!(
c.mint_capi_key("x", 1).await,
Err(AuthError::Protocol(_))
));
}
#[tokio::test]
async fn login_without_jsessionid_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let mut c = client(&server);
assert!(matches!(
c.login("ACCESS", None).await,
Err(AuthError::Protocol(_))
));
}
#[tokio::test]
async fn login_failure_status_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(401)
.append_header("Set-Cookie", "JSESSIONID=SID; Path=/")
.set_body_json(serde_json::json!({
"errors": { "status": 401, "code": "user-invalid-access-token" }
})),
)
.mount(&server)
.await;
let mut c = client(&server);
assert!(matches!(
c.login("ACCESS", None).await,
Err(AuthError::Protocol(_))
));
}
#[tokio::test]
async fn set_current_account_posts_to_setcurrent() {
let server = MockServer::start().await;
let c = logged_in(&server).await;
Mock::given(method("POST"))
.and(path("/accounts/setcurrent/424242"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
c.set_current_account(424242).await.unwrap();
}
#[tokio::test]
async fn set_current_account_surfaces_a_refusal() {
let server = MockServer::start().await;
let c = logged_in(&server).await;
Mock::given(method("POST"))
.and(path("/accounts/setcurrent/1"))
.respond_with(ResponseTemplate::new(403).set_body_string("nope"))
.mount(&server)
.await;
assert!(matches!(
c.set_current_account(1).await,
Err(AuthError::Protocol(_))
));
}
#[tokio::test]
async fn login_sends_utm_attribution_per_flow() {
for (flow, campaign) in [
(LoginFlow::Loopback, "loopback"),
(LoginFlow::Device, "device"),
] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(body_string_contains("\"utm_source\":\"redisctl\""))
.and(body_string_contains("\"utm_medium\":\"cli\""))
.and(body_string_contains(format!(
"\"utm_campaign\":\"{campaign}\""
)))
.respond_with(
ResponseTemplate::new(200).append_header("Set-Cookie", "JSESSIONID=S"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"csrfToken": { "csrf_token": "CSRF", "csrf_enabled": true, "errors": [] }
})))
.mount(&server)
.await;
let mut c = SmApiClient::new(Url::parse(&server.uri()).unwrap(), flow);
c.login("ACCESS", None).await.unwrap();
}
}
#[tokio::test]
async fn mfa_retry_still_carries_utm() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(body_string_contains("\"mfa_code\":\"123456\""))
.and(body_string_contains("\"mfa_type\":\"Totp\""))
.and(body_string_contains("\"utm_source\":\"redisctl\""))
.respond_with(ResponseTemplate::new(200).append_header("Set-Cookie", "JSESSIONID=S"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"csrfToken": { "csrf_token": "CSRF", "csrf_enabled": true, "errors": [] }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(401)
.append_header("Set-Cookie", "JSESSIONID=CH; Path=/")
.set_body_json(serde_json::json!({
"errors": { "status": 401, "code": "user-mfa-required" }
})),
)
.mount(&server)
.await;
let mut c = client(&server);
assert!(matches!(
c.login("ACCESS", None).await,
Err(AuthError::MfaRequired { .. })
));
c.complete_mfa("ACCESS", None, "123456").await.unwrap();
}
#[tokio::test]
async fn login_social_migration_required_is_classified() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"errors": {
"status": 422,
"code": "user-agreement-for-social-login-migration-missing"
}
})))
.mount(&server)
.await;
let mut c = client(&server);
assert!(matches!(
c.login("ACCESS", None).await,
Err(AuthError::MigrationRequired)
));
}
#[tokio::test]
async fn login_reports_mfa_challenge_with_factors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(401)
.append_header("Set-Cookie", "JSESSIONID=CHALLENGED; Path=/")
.set_body_json(serde_json::json!({
"errors": { "status": 401, "code": "user-mfa-required",
"params": [{ "type": "totp" }] }
})),
)
.mount(&server)
.await;
let mut c = client(&server);
match c.login("ACCESS", None).await {
Err(AuthError::MfaRequired { factors }) => assert_eq!(factors, vec!["totp"]),
other => panic!("expected MfaRequired, got {other:?}"),
}
}
#[tokio::test]
async fn complete_mfa_reuses_the_challenged_session() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(header("cookie", "JSESSIONID=CHALLENGED"))
.and(body_string_contains("\"mfa_code\":\"123456\""))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"csrfToken": { "csrf_token": "CSRF", "csrf_enabled": true, "errors": [] }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(
ResponseTemplate::new(401)
.append_header("Set-Cookie", "JSESSIONID=CHALLENGED; Path=/")
.set_body_json(serde_json::json!({
"errors": { "status": 401, "code": "user-mfa-required" }
})),
)
.mount(&server)
.await;
let mut c = client(&server);
assert!(matches!(
c.login("ACCESS", None).await,
Err(AuthError::MfaRequired { .. })
));
c.complete_mfa("ACCESS", None, "123456").await.unwrap();
}
#[tokio::test]
async fn complete_mfa_without_a_challenge_errors() {
let server = MockServer::start().await;
let mut c = client(&server);
assert!(matches!(
c.complete_mfa("ACCESS", None, "123456").await,
Err(AuthError::Protocol(_))
));
}
#[tokio::test]
async fn mfa_error_codes_are_classified() {
for (code, want_invalid) in [("mfa-invalid-code", true), ("mfa-quota-exceeded", false)] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"errors": { "status": 400, "code": code }
})))
.mount(&server)
.await;
let mut c = client(&server);
let got = c.login("ACCESS", None).await;
if want_invalid {
assert!(matches!(got, Err(AuthError::MfaInvalidCode)), "{code}");
} else {
assert!(matches!(got, Err(AuthError::MfaQuotaExceeded)), "{code}");
}
}
}
#[test]
fn mfa_factors_tolerates_shapes_we_have_not_seen() {
assert!(mfa_factors(None).is_empty());
assert!(mfa_factors(Some(&serde_json::json!({}))).is_empty());
assert_eq!(
mfa_factors(Some(&serde_json::json!(
r#"[{"factorType":"token:software:totp"}]"#
))),
vec!["token:software:totp"]
);
assert!(mfa_factors(Some(&serde_json::json!(7))).is_empty());
}
#[tokio::test]
async fn login_sends_sm_id_token_only_when_provided() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(header("sm-id-token", "IDT"))
.respond_with(ResponseTemplate::new(200).append_header("Set-Cookie", "JSESSIONID=S"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/csrf"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"csrfToken": { "csrf_token": "C" }
})))
.mount(&server)
.await;
let mut c = client(&server);
assert!(c.login("ACCESS", Some("IDT")).await.is_ok());
}
}