Skip to main content

redisctl_core/auth/
sm_api.rs

1//! SM API exchange: turn Okta tokens into a Redis Cloud CAPI key.
2//!
3//! Sequence:
4//! 1. `POST /login` with `Authorization: Bearer <access_token>` → `Set-Cookie: JSESSIONID`.
5//!    **No `Sm-Id-Token` header for Google/GitHub logins** — sending it drives a SAML
6//!    account-mapping path that 400s; pass it only for SSO.
7//! 2. `GET /csrf` → token nested at `csrfToken.csrf_token`; echoed back as `X-CSRF-Token`.
8//! 3. `GET /users/me`, `GET /accounts` → resolve the user + account (+ account api key).
9//! 4. `POST /accounts/cloud-api/cloudApiAccessKey` to enable CAPI — a `400
10//!    account_api_key_already_exists` means it's already on (idempotent).
11//! 5. `POST /accounts/cloud-api/cloudApiKeys` → mints the user secret key (`secret_key`).
12//!
13//! This workspace's `reqwest` has no cookie-store feature, so the `JSESSIONID` cookie and
14//! CSRF token are carried manually on each request.
15
16use serde::Deserialize;
17use url::Url;
18
19use super::{AuthError, default_http_client, endpoint, truncate};
20
21/// SM error code on the CAPI-enable call when the signed-in user is not the account owner. Note
22/// this endpoint nests its errors as a JSON-encoded string, so it is matched on the body rather
23/// than through the `/login` error envelope.
24const INSUFFICIENT_PERMISSION_CODE: &str = "insufficient-permission";
25/// SM error code when the *account* cannot have programmatic access at all. On this flow the
26/// caller has already passed the role gate and mints for themselves, so the remaining cause is
27/// the account's `IsApiEnabled` flag being off — which only Redis can change.
28const FORBIDDEN_REQUEST_CODE: &str = "forbidden-request";
29/// SM error code returned when a password-only account must be linked to social sign-in before it
30/// can authenticate this way. The consent step is console-only.
31const SOCIAL_MIGRATION_REQUIRED_CODE: &str = "user-agreement-for-social-login-migration-missing";
32/// SM error code for an MFA challenge on `/login`.
33const MFA_REQUIRED_CODE: &str = "user-mfa-required";
34/// SM error code for a rejected MFA code.
35const MFA_INVALID_CODE: &str = "mfa-invalid-code";
36/// SM error code for an `mfa_type` it does not recognise — a client bug, not a user error.
37const MFA_INVALID_TYPE_CODE: &str = "mfa-invalid-type";
38/// SM error code for too many MFA attempts.
39const MFA_QUOTA_EXCEEDED_CODE: &str = "mfa-quota-exceeded";
40/// The only MFA type we submit. SM also knows SMS and Email, but TOTP is what a CLI can prompt for.
41///
42/// Case matters: SM resolves this with `EnumMFAType.toEnum`, which compares against the enum
43/// constant name (`SMS`, `Totp`, `Email`) verbatim — there is no custom `toString`. Sending
44/// `"totp"` is rejected with `mfa-invalid-type`. RedisInsight sends the same `"Totp"`.
45const MFA_TYPE_TOTP: &str = "Totp";
46
47/// Attribution sent on `POST /login`, mirroring what RedisInsight sends. SM records these on the
48/// signup path (`registerOktaUser` → `buildRegistrationItem`), so without them a user whose first
49/// contact with Redis Cloud is `cloud auth login` is registered with no originating tool.
50const UTM_SOURCE: &str = "redisctl";
51/// Coarse channel, kept stable so dashboards can group on it; the flow goes in `utm_campaign`.
52const UTM_MEDIUM: &str = "cli";
53
54/// Which login flow produced the tokens. Reported as `utm_campaign`, so interactive and
55/// agent-driven sign-ins can be told apart in analytics.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum LoginFlow {
58    /// Browser on the same machine, redirect caught on loopback.
59    Loopback,
60    /// Device-authorization grant — headless machines and agents.
61    Device,
62    /// `cloud auth switch`: no browser at all, a stored refresh token stands in for the sign-in.
63    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
76/// SM API client. Stateless until [`SmApiClient::login`] establishes a session.
77pub struct SmApiClient {
78    base_url: Url,
79    http: reqwest::Client,
80    session: Option<Session>,
81    /// Which flow produced the tokens, reported to SM as `utm_campaign`.
82    flow: LoginFlow,
83    /// JSESSIONID from an MFA-challenged `/login`. SM keeps the challenge state in that session,
84    /// so [`SmApiClient::complete_mfa`] must reuse this exact cookie — a fresh session has no
85    /// challenge to verify against.
86    pending_mfa_cookie: Option<String>,
87}
88
89struct Session {
90    /// Full cookie header value, e.g. `JSESSIONID=abc123`.
91    cookie: String,
92    csrf: String,
93}
94
95/// SM's error envelope: `{"errors": {"status": 401, "code": "…", "params": …}}`.
96#[derive(Debug, Default, Deserialize)]
97struct SmErrorEnvelope {
98    errors: Option<SmError>,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct SmError {
103    code: Option<String>,
104    /// Free-form; for MFA it carries the offered factors. Shape is not contractual, so it is
105    /// parsed best-effort and never allowed to fail the classification.
106    params: Option<serde_json::Value>,
107}
108
109/// Pull the error code out of an SM response body, if it has the usual envelope.
110fn 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
116/// Best-effort extraction of MFA factor names from SM's `params`. Returns an empty list rather
117/// than failing: the factor list is cosmetic (it only enriches the prompt).
118fn 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                // `params` is sometimes a JSON-encoded string; try one level of nesting.
123                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/// The authenticated user (`GET /users/me`), trimmed to what the bootstrap needs.
152#[derive(Debug, Clone, Deserialize)]
153pub struct SmUser {
154    /// User id (string in the API); parse via [`SmUser::user_account`] for the mint call.
155    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    /// The numeric user account id used as `user_account` when minting a CAPI key.
166    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/// An account (`GET /accounts`), trimmed to what the bootstrap needs.
174#[derive(Debug, Clone, Deserialize)]
175pub struct SmAccount {
176    pub id: u64,
177    #[serde(default)]
178    pub name: Option<String>,
179    /// The account-level CAPI access key (`x-api-key`), present once CAPI is enabled.
180    #[serde(default)]
181    pub api_access_key: Option<String>,
182}
183
184/// A minted CAPI user key. `secret_key` is redacted from `Debug`.
185#[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
217/// Every `error_code` in an SM error body, in order.
218///
219/// Same nesting as [`allowed_roles`]: `errors` is a JSON-encoded string holding the list. Used to
220/// classify on the *set* of codes present rather than on a substring, so a generic code cannot be
221/// matched out of an unrelated envelope.
222fn 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
253/// Roles SM named as sufficient in an `insufficient-permission` body, formatted for a message.
254///
255/// SM nests its error list as a JSON-encoded *string*, so reaching the params means parsing the
256/// body and then parsing `errors` again. Walking the decoded structure — rather than scanning the
257/// raw text — keeps this independent of key order inside each param object and of how role names
258/// are spelled. Falls back to `owner`, the only role that has ever held this permission, when
259/// nothing is parseable, so the message is never empty.
260fn 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            // `errors` is normally a JSON-encoded string; tolerate it arriving already decoded.
289            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        // Only ever `owner` in practice; phrase both cases so the message reads correctly if the
299        // set widens (SM has feature flags for exactly that).
300        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    /// Build a client for the SM API base (e.g. `https://<sm-api-host>/api/v1`).
308    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    /// Build with a caller-provided reqwest client (tests / shared client).
319    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    /// Establish a session: `POST /login` with the Okta access token, then fetch the CSRF
330    /// token. Pass `sm_id_token` only for SSO logins (omit for Google/GitHub).
331    ///
332    /// Returns [`AuthError::MfaRequired`] when SM challenges the login; call
333    /// [`SmApiClient::complete_mfa`] on this same client to finish it.
334    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    /// Finish an MFA-challenged login with a TOTP code, reusing the challenged session.
343    ///
344    /// Errors with [`AuthError::Protocol`] if no challenge is outstanding — submitting a code on a
345    /// fresh session cannot work, because SM verifies it against challenge state held in the
346    /// session it issued.
347    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        // Read the cookie before consuming the body: on an MFA challenge the session carrying the
394        // challenge arrives on the *error* response, and the retry must reuse it.
395        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                // Keep the challenged session for the retry; SM may or may not re-issue it.
422                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            // We sent an mfa_type SM does not accept. Never the user's fault, and retrying the
432            // same request cannot help, so say so plainly rather than blaming their code.
433            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    /// `GET /users/me`.
458    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    /// `GET /accounts`.
465    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    /// `POST /accounts/cloud-api/cloudApiAccessKey` — enable programmatic access. Idempotent:
473    /// a `400 account_api_key_already_exists` is treated as success.
474    /// Returns whether this call is what switched programmatic access on, so a caller can say so
475    /// rather than enabling an account-wide setting silently.
476    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        // The caller's role does not carry the CAPI permission. Nothing the CLI can do, but
492        // someone who holds the role can enable it once — after which the call above is a no-op.
493        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    /// `POST /accounts/cloud-api/cloudApiKeys` — mint a named user secret key.
510    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        // secret_key may be top-level or nested under `cloudApiKey`.
524        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    /// `GET /accounts/cloud-api/cloudApiKeys` — list existing CAPI key names. Best-effort: used
542    /// only to warn about `redisctl-*` key sprawl at login, so tolerant of response shape.
543    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        // Tolerate a top-level array or a wrapper object ({"cloudApiKeys":[...]}).
552        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    /// `GET /accounts/cloud-api/cloudApiKeys`, as `(id, name)` pairs.
568    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    /// `DELETE /accounts/cloud-api/cloudApiKeys/{id}` — revoke a minted key server-side.
594    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    /// Rebind the session to `account_id` (`POST /accounts/setcurrent/{id}`).
624    ///
625    /// Every CAPI call resolves the account from the session — `createApiSecretKey` uses the
626    /// session's `userAccountId` — so this must happen *before* enabling access or minting, or the
627    /// key lands on the previous account. Annotated `LEGACY_ONLY` server-side, which the JSESSIONID
628    /// established by [`SmApiClient::login`] satisfies.
629    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
676/// Pull the `JSESSIONID` value out of the response's `Set-Cookie` headers.
677fn 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        // /users/me only matches when the session cookie + csrf header are present.
728        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    /// Revocation targets one key by id, so the id has to come out of the listing.
798    #[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    /// Only an owner may enable programmatic access. SM says so precisely, but nests the code in a
841    /// JSON-encoded string, so it is matched on the body — this pins that it is still classified.
842    #[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        // The role SM named is carried through, not a hardcoded string.
854        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    /// `allowed-roles` reaches us escaped inside a JSON-encoded string. Parsing the structure
863    /// (rather than scanning the text) has to be independent of key order within a param and of
864    /// how role names are spelled, and anything unreadable must still say something accurate.
865    #[test]
866    fn allowed_roles_reads_the_param_regardless_of_shape() {
867        // The real wire shape: `errors` is a JSON-encoded string.
868        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        // `value` before `key`, plus a second param that also has a value list: must not pick the
875        // wrong one. JSON object order carries no meaning, so this cannot be assumed away.
876        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        // Several roles, including a name that is neither lowercase nor underscore-only.
883        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        // No params, not JSON, or the param missing: name the only role that has ever held it.
890        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    /// A generic code must not be matched out of an unrelated envelope.
902    #[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    /// The account's own API access being off is a different failure from a role problem: no role
916    /// can fix it, so it must not be reported as "ask someone with the owner role".
917    #[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        // Debug must not leak the secret.
965        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    /// Every account-scoped call resolves the account from the session, so a switch has to be a
1037    /// real request to the documented path — the mock only matches that exact URL.
1038    #[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    /// A refused switch must fail loudly; silently continuing would mint on the previous account.
1051    #[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    /// SM records `utm_*` on the signup path, so a first-ever login through redisctl must carry
1067    /// attribution or the tool is invisible in signup analytics. The mock matches only if all three
1068    /// fields are present, and `utm_campaign` distinguishes the two flows.
1069    #[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    /// The MFA retry must keep the attribution alongside the code.
1101    #[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            // Case-sensitive on SM's side; lowercase is rejected as mfa-invalid-type.
1108            .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    /// A password-only account that hasn't been linked to social sign-in gets its own error, so
1140    /// the CLI can point the user at the one-time console step instead of a generic failure.
1141    #[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    /// SM answers the first `/login` with `user-mfa-required`; the factors come back for the prompt.
1162    #[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    /// The regression that matters: SM holds the challenge in the session it issued on the *401*,
1185    /// so the retry must send that exact JSESSIONID back. The mock only matches if it does.
1186    #[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        // Unmatched-request fallback: the challenge itself.
1204        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        // Succeeds only because the challenged cookie was carried over.
1222        c.complete_mfa("ACCESS", None, "123456").await.unwrap();
1223    }
1224
1225    /// A code with no outstanding challenge can never succeed against SM, so fail locally rather
1226    /// than sending a request that would trip over missing session state.
1227    #[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        // A JSON-encoded string payload, which SM sometimes uses for `params`.
1263        assert_eq!(
1264            mfa_factors(Some(&serde_json::json!(
1265                r#"[{"factorType":"token:software:totp"}]"#
1266            ))),
1267            vec!["token:software:totp"]
1268        );
1269        // Never panics on unexpected scalars.
1270        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        // This mock matches ONLY when sm-id-token is present.
1277        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        // With the id token, login matches the header-gated mock and succeeds.
1292        assert!(c.login("ACCESS", Some("IDT")).await.is_ok());
1293    }
1294}