From c730c06f190c20a4af159da6cf8f384588a0f9b9 Mon Sep 17 00:00:00 2001 From: Sammy Khamis Date: Wed, 6 May 2026 12:27:59 -1000 Subject: [PATCH] Refactor FxA state machine to simplify transitions --- .../fxa-client/src/state_machine/README.md | 105 ++-- .../fxa-client/src/state_machine/display.rs | 39 +- .../fxa-client/src/state_machine/helpers.rs | 456 ++++++++++++++++++ .../internal_machines/auth_issues.rs | 104 ---- .../internal_machines/authenticating.rs | 290 ----------- .../internal_machines/connected.rs | 103 ---- .../internal_machines/disconnected.rs | 134 ----- .../state_machine/internal_machines/mod.rs | 308 ------------ .../internal_machines/uninitialized.rs | 110 ----- .../fxa-client/src/state_machine/mod.rs | 261 ++++++---- .../src/state_machine/transitions.rs | 308 ++++++++++++ 11 files changed, 981 insertions(+), 1237 deletions(-) create mode 100644 components/fxa-client/src/state_machine/helpers.rs delete mode 100644 components/fxa-client/src/state_machine/internal_machines/auth_issues.rs delete mode 100644 components/fxa-client/src/state_machine/internal_machines/authenticating.rs delete mode 100644 components/fxa-client/src/state_machine/internal_machines/connected.rs delete mode 100644 components/fxa-client/src/state_machine/internal_machines/disconnected.rs delete mode 100644 components/fxa-client/src/state_machine/internal_machines/mod.rs delete mode 100644 components/fxa-client/src/state_machine/internal_machines/uninitialized.rs create mode 100644 components/fxa-client/src/state_machine/transitions.rs diff --git a/components/fxa-client/src/state_machine/README.md b/components/fxa-client/src/state_machine/README.md index e8c35bf70b..b9a69a555e 100644 --- a/components/fxa-client/src/state_machine/README.md +++ b/components/fxa-client/src/state_machine/README.md @@ -1,85 +1,58 @@ -# The Public FxA State Machine +# The FxA State Machine -The public FxA state machine tracks a user's authentication state as they perform operations on their account. -The state machine, its states, and its events are visible to the consumer applications. -Applications generally track the state and update the UI based on it, for example providing a login button for the `Disconnected` state and link to the FxA account management page for the `Connected` state. +The FxA state machine tracks a user's authentication state as they perform operations on their account. +The state machine, its states, and its events are visible to consumer applications (Firefox iOS, Firefox Android). +Apps generally watch the state and update the UI based on it - e.g. showing a login button for `Disconnected`, or a link to the FxA account management page for `Connected`. -The public state machine events correspond to user actions, for example clicking the login button or completing the OAuth flow. -The public state machine is non-deterministic -- from a given state and event, there are multiple possibilities for the next state. -Usually there are two possible transitions: one for a successful operation and one for a failed one. -For example, when completing an oauth flow, if the operation is successful the state machine transitions to the `Connected` state, while if it fails it stays in the `Authenticating` state. +Events correspond to user actions or runtime triggers (clicking the login button, completing OAuth, recovering from an auth error). From a given state and event, the FSM may produce multiple possible next states depending on the result of underlying network calls, usually one for success and one for failure. -Here is an overview containing some of the states and transitions: +For example, when completing an OAuth flow: a successful `CompleteOAuthFlow` transitions from `Authenticating` to `Connected`; a failed one transitions back to the state we were authenticating from. -```mermaid -graph LR; - Disconnected --> |"BeginOAuthFlow(Success)"| Authenticating - Disconnected --> |"BeginOAuthFlow(Failure)"| Disconnected - Disconnected --> |"BeginPairingFlow(Success)"| Authenticating - Disconnected --> |"BeginPairingFlow(Failure)"| Disconnected - Authenticating --> |"CompleteOAuthFlow(Success)"| Connected - Authenticating --> |"CompleteOAuthFlow(Failure)"| Authenticating - Authenticating --> |"CancelOAuthFlow"| Disconnected - Connected --> |"Disconnect"| Disconnected +## High-level - classDef default fill:#0af, color:black, stroke:black -``` +There are two layers: -# The Internal State Machines +1. **`transitions.rs`** — Each `match` arm reads as: do the work (calling methods on the `RetryingAccount` wrapper), attach the target state for the error path with `.to_state_machine_err(|| target)?`, return the success state. Returns `Result`, the `Err` variant carries both the error cause (for logging) and the target state to land in. +2. **`helpers.rs`** — the supporting types: + - [`RetryingAccount`] wraps a `&mut FirefoxAccount` and exposes only the methods the FSM uses, with retry policy applied automatically. Holding a `&mut RetryingAccount` instead of a `&mut FirefoxAccount` makes it hard to call a network method without retry. + - [`StateMachineErr`] + [`ResultExt::to_state_machine_err()`] extension trait give the `?` ergonomics for "on error, transition to this state". + - [`RetryPolicy`] holds the network-retry count and auth-recovery flag. -For each public state, we also define an internal state machine that represents the process of transitioning out of that state. -Internal state machine states correspond to `FirefoxAccount` method calls and events correspond to call results. -Unlike the public state machine, the internal state machines are deterministic meaning that each `(state, event)` pair always results in the same next state. +The driver in `mod.rs` validates the `Initialize` invariant, builds a `RetryingAccount`, calls `transition()` once, routes the error (if any) through `convert_log_report_error` for logging/Sentry, commits the new state, and fires `on_auth_issues()` if applicable. -There are two terminal states for the internal state machines: - - `Complete(new_state)`: Complete the process and transition the public state machine to a new state - - `Cancel`: Cancel the process and don't change the current public state. +Adding a new event is straightforward: add a `match` arm in `transition()`. If the event needs a new account method, add a one-line wrapper to `RetryingAccount` — that's the moment to think about retry semantics for the new operation. -Here are some example internal state machines: - -## Disconnected +## State diagram ```mermaid -graph TD; - Authenticating["Complete(Authenticating)"]:::terminal - BeginOAuthFlow --> |BeginOAuthFlowSuccess| Authenticating - BeginPairingFlow --> |BeginPairingFlowSuccess| Authenticating - BeginOAuthFlow --> |Error| Cancel:::terminal - BeginPairingFlow --> |Error| Cancel:::terminal +graph LR; + Uninitialized -->|"Initialize"| Disconnected + Uninitialized -->|"Initialize"| Connected + Uninitialized -->|"Initialize"| AuthIssues + Disconnected -->|"BeginOAuthFlow / BeginPairingFlow (Ok)"| Authenticating + Disconnected -->|"BeginOAuthFlow / BeginPairingFlow (Err)"| Disconnected + Authenticating -->|"CompleteOAuthFlow (Ok)"| Connected + Authenticating -->|"CompleteOAuthFlow / Begin*Flow (Err) → initial_state"| InitialState[Disconnected / Connected / AuthIssues] + Authenticating -->|"CancelOAuthFlow → initial_state"| InitialState + Authenticating -->|"InitializeDevice (Err)"| Disconnected + Authenticating -->|"Disconnect"| Disconnected + Connected -->|"Disconnect"| Disconnected + Connected -->|"BeginOAuthFlow (Ok) — new OAuth flow"| Authenticating + Connected -->|"CheckAuthorizationStatus (inactive / Err)"| AuthIssues + Connected -->|"CallGetProfile (Err)"| AuthIssues + AuthIssues -->|"BeginOAuthFlow (Ok)"| Authenticating + AuthIssues -->|"Disconnect"| Disconnected classDef default fill:#0af, color:black, stroke:black - classDef terminal fill:#FC766A, stroke: black; ``` -## Authenticating - -```mermaid -graph TD; - Connected["Complete(Connected)"]:::terminal - CompleteOAuthFlow --> |CompleteOAuthFlowSuccess| InitializeDevice - CompleteOAuthFlow --> |Error| Cancel:::terminal - InitializeDevice --> |InitializeDeviceSuccess| Connected - InitializeDevice --> |Error| Cancel:::terminal +`Authenticating { initial_state }` tracks where the user came from. Error and cancel paths from `Authenticating` return to `initial_state.into()` (not always `Disconnected`) — so a re-auth attempt from `AuthIssues` that the user cancels lands back at `AuthIssues`, and an OAuth flow started from `Connected` that errors out keeps the user at `Connected`. The exception is `InitializeDevice` errors, which always land at `Disconnected`. A `CompleteOAuthFlow` success from `Authenticating { initial_state: Connected }` skips `InitializeDevice` because the device is already initialized. - classDef default fill:#0af, color:black, stroke:black - classDef terminal fill:#FC766A, stroke: black; -``` +## Retry behavior -## Uninitialized +`RetryingAccount` applies this policy: -This is the initial state for the public state machine (not shown in the diagram above). +- **Network errors** retry up to 3 times. +- **Auth errors** trigger a single recovery attempt: clear the access token cache, call `check_authorization_status`, and (if still active) retry the operation once. -```mermaid -graph TD; - Disconnected["Complete(Disconnected)"]:::terminal - Connected["Complete(Connected)"]:::terminal - AuthIssues["Complete(AuthIssues)"]:::terminal - GetAuthState --> |"GetAuthStateSuccess(Disconnected)"| Disconnected:::terminal - GetAuthState --> |"GetAuthStateSuccess(AuthIssues)"| AuthIssues:::terminal - GetAuthState --> |"GetAuthStateSuccess(Connected)"| EnsureCapabilities - EnsureCapabilities --> |EnsureCapabilitiesSuccess| Connected:::terminal - EnsureCapabilities --> |Error| AuthIssues:::terminal - - classDef default fill:#0af, color:black, stroke:black - classDef terminal fill:#FC766A, stroke: black; -``` +Methods that auto-recover from auth errors: `complete_oauth_flow`, `begin_oauth_flow`, `begin_pairing_flow`, `get_profile`. Methods that don't (auth errors are FSM-recoverable, not operation-recoverable): `initialize_device`, `ensure_capabilities`, `check_authorization_status`. The `EnsureDeviceCapabilities` auth-error case is handled at the FSM level — the transition arm matches on the error and dispatches to `CheckAuthorizationStatus`. diff --git a/components/fxa-client/src/state_machine/display.rs b/components/fxa-client/src/state_machine/display.rs index 99e090016a..8e92c5770f 100644 --- a/components/fxa-client/src/state_machine/display.rs +++ b/components/fxa-client/src/state_machine/display.rs @@ -10,7 +10,7 @@ //! Also, they must not use the string "auth" since Sentry will filter that out. //! Use "ath" instead. -use super::{internal_machines, FxaEvent, FxaState}; +use super::{FxaEvent, FxaState}; use std::fmt; impl fmt::Display for FxaState { @@ -41,40 +41,3 @@ impl fmt::Display for FxaEvent { write!(f, "{name}") } } - -impl fmt::Display for internal_machines::State { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Self::GetAuthState => write!(f, "GetAthState"), - Self::BeginOAuthFlow { .. } => write!(f, "BeginOAthFlow"), - Self::BeginPairingFlow { .. } => write!(f, "BeginPairingFlow"), - Self::CompleteOAuthFlow { .. } => write!(f, "CompleteOAthFlow"), - Self::InitializeDevice => write!(f, "InitializeDevice"), - Self::EnsureDeviceCapabilities => write!(f, "EnsureDeviceCapabilities"), - Self::CheckAuthorizationStatus => write!(f, "CheckAuthorizationStatus"), - Self::Disconnect => write!(f, "Disconnect"), - Self::GetProfile => write!(f, "GetProfile"), - Self::Complete(state) => write!(f, "Complete({state})"), - Self::Cancel => write!(f, "Cancel"), - } - } -} - -impl fmt::Display for internal_machines::Event { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let name = match self { - Self::GetAuthStateSuccess { .. } => "GetAthStateSuccess", - Self::BeginOAuthFlowSuccess { .. } => "BeginOAthFlowSuccess", - Self::BeginPairingFlowSuccess { .. } => "BeginPairingFlowSuccess", - Self::CompleteOAuthFlowSuccess => "CompleteOAthFlowSuccess", - Self::InitializeDeviceSuccess => "InitializeDeviceSuccess", - Self::EnsureDeviceCapabilitiesSuccess => "EnsureDeviceCapabilitiesSuccess", - Self::CheckAuthorizationStatusSuccess { .. } => "CheckAuthorizationStatusSuccess", - Self::DisconnectSuccess => "DisconnectSuccess", - Self::GetProfileSuccess => "GetProfileSuccess", - Self::CallError => "CallError", - Self::EnsureCapabilitiesAuthError => "EnsureCapabilitiesAthError", - }; - write!(f, "{name}") - } -} diff --git a/components/fxa-client/src/state_machine/helpers.rs b/components/fxa-client/src/state_machine/helpers.rs new file mode 100644 index 0000000000..fc82e66c84 --- /dev/null +++ b/components/fxa-client/src/state_machine/helpers.rs @@ -0,0 +1,456 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! State machine helpers: error wrapper, target-state extension trait, and +//! a `RetryingAccount` wrapper. +//! +//! Holding `&mut RetryingAccount` instead of `&mut FirefoxAccount` makes it +//! structurally impossible to call into the network without retry. + +use crate::{ + internal::FirefoxAccount, DeviceCapability, DeviceType, Error, FxaError, FxaRustAuthState, + FxaState, LocalDevice, Result, +}; +use error_support::GetErrorHandling; + +/// Error returned from `transition()`. +/// +/// Causes are boxed because `Error` is ~120 bytes; keeping the variants small +/// avoids inflating every transition's `Result`. +#[derive(Debug)] +pub enum StateMachineErr { + /// Operational error (network, auth). Driver logs the cause and commits + /// `target` as the new public state. + Handled { cause: Box, target: FxaState }, + /// Programming error. Driver returns `Err(*cause)`; public state unchanged. + Fatal(Box), +} + +impl StateMachineErr { + /// Programmatic errors (logic / invalid-transition) become `Fatal` and + /// ignore `target_if_handled`; everything else becomes `Handled`. + pub fn new(cause: Error, target_if_handled: FxaState) -> Self { + match cause { + Error::StateMachineLogicError(_) | Error::InvalidStateTransition(_) => { + Self::Fatal(Box::new(cause)) + } + other => Self::Handled { + cause: Box::new(other), + target: target_if_handled, + }, + } + } +} + +/// Attaches a target [`FxaState`] to a `Result` so transitions can +/// land somewhere specific on failure with `?`. +/// +/// Programming errors (logic / invalid-transition) bypass `target` and are +/// wrapped as [`StateMachineErr::Fatal`] so the driver propagates them. +/// +/// ```ignore +/// account.complete_oauth_flow(&code, &state) +/// .to_state_machine_err(|| initial_state.into())?; +/// ``` +pub trait ResultExt { + fn to_state_machine_err( + self, + f: impl FnOnce() -> FxaState, + ) -> std::result::Result; +} + +impl ResultExt for Result { + fn to_state_machine_err( + self, + f: impl FnOnce() -> FxaState, + ) -> std::result::Result { + self.map_err(|cause| match cause { + Error::StateMachineLogicError(_) | Error::InvalidStateTransition(_) => { + StateMachineErr::Fatal(Box::new(cause)) + } + other => StateMachineErr::Handled { + cause: Box::new(other), + target: f(), + }, + }) + } +} + +/// Retry policy applied by [`RetryingAccount`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RetryPolicy { + /// Number of times to retry a call after a transient network error. + pub network_retries: u8, + /// Whether an authentication error triggers a single recovery attempt + /// (clear access token cache → check_authorization_status → retry on success). + pub auth_retry_with_cache_clear: bool, +} + +impl RetryPolicy { + pub const DEFAULT: RetryPolicy = RetryPolicy { + network_retries: 3, + auth_retry_with_cache_clear: true, + }; +} + +/// Exposes only the [`FirefoxAccount`] methods the FSM uses, each with retry +/// policy applied. Holding `&mut RetryingAccount` rather than `&mut FirefoxAccount` +/// makes bypassing retry a compile error. +pub struct RetryingAccount<'a> { + inner: &'a mut FirefoxAccount, + policy: RetryPolicy, +} + +impl<'a> RetryingAccount<'a> { + pub fn new(inner: &'a mut FirefoxAccount) -> Self { + Self { + inner, + policy: RetryPolicy::DEFAULT, + } + } + + pub fn complete_oauth_flow(&mut self, code: &str, state: &str) -> Result<()> { + self.with_auth_recovery(|a| a.complete_oauth_flow(code, state)) + } + + /// Cancels any existing OAuth flow before starting a new one. + pub fn begin_oauth_flow( + &mut self, + service: &str, + scopes: &[&str], + entrypoint: &str, + ) -> Result { + self.inner.cancel_existing_oauth_flows(); + self.with_auth_recovery(|a| a.begin_oauth_flow(service, scopes, entrypoint)) + } + + /// Cancels any existing OAuth flow before starting a new one. + pub fn begin_pairing_flow( + &mut self, + pairing_url: &str, + service: &str, + scopes: &[&str], + entrypoint: &str, + ) -> Result { + self.inner.cancel_existing_oauth_flows(); + self.with_auth_recovery(|a| a.begin_pairing_flow(pairing_url, service, scopes, entrypoint)) + } + + /// Auth errors land the FSM at `Disconnected`; no operation-level recovery. + pub fn initialize_device( + &mut self, + name: &str, + device_type: DeviceType, + capabilities: &[DeviceCapability], + ) -> Result { + self.with_retry(|a| a.initialize_device(name, device_type, capabilities)) + } + + /// Auth errors propagate so the FSM can drive its own recovery via + /// `CheckAuthorizationStatus`. See + /// . + pub fn ensure_capabilities( + &mut self, + capabilities: &[DeviceCapability], + ) -> Result { + self.with_retry(|a| a.ensure_capabilities(capabilities)) + } + + pub fn check_authorization_status(&mut self) -> Result { + self.with_retry(|a| a.check_authorization_status()) + .map(|info| info.active) + } + + pub fn get_profile(&mut self) -> Result<()> { + self.with_auth_recovery(|a| { + a.get_profile(true)?; + Ok(()) + }) + } + + pub fn get_auth_state(&mut self) -> FxaRustAuthState { + self.inner.get_auth_state() + } + + pub fn disconnect(&mut self) { + self.inner.disconnect() + } + + /// Panics if called before the driver has processed `Initialize`. + pub fn device_config(&self) -> &crate::DeviceConfig { + self.inner + .device_config + .as_ref() + .expect("device_config must be set before transition runs (Initialize event seeds it)") + } + + fn with_retry(&mut self, mut op: impl FnMut(&mut FirefoxAccount) -> Result) -> Result { + let mut network_retries: u8 = 0; + loop { + match op(self.inner) { + Ok(v) => return Ok(v), + Err(e) => { + if matches!(e, Error::StateMachineLogicError(_)) { + return Err(e); + } + crate::warn!("handling error: {e}"); + match e.get_error_handling().err { + FxaError::Network if network_retries < self.policy.network_retries => { + network_retries += 1; + continue; + } + _ => return Err(e), + } + } + } + } + } + + /// Like `with_retry`, plus a single auth-error recovery: clear the access + /// token cache, check authorization, and retry if the account is still + /// active server-side. + fn with_auth_recovery( + &mut self, + mut op: impl FnMut(&mut FirefoxAccount) -> Result, + ) -> Result { + let mut network_retries: u8 = 0; + let mut auth_retried = false; + loop { + match op(self.inner) { + Ok(v) => return Ok(v), + Err(e) => { + if matches!(e, Error::StateMachineLogicError(_)) { + return Err(e); + } + crate::warn!("handling error: {e}"); + match e.get_error_handling().err { + FxaError::Network if network_retries < self.policy.network_retries => { + network_retries += 1; + continue; + } + FxaError::Authentication + if self.policy.auth_retry_with_cache_clear && !auth_retried => + { + self.inner.clear_access_token_cache(); + match self.inner.check_authorization_status() { + Ok(status) if status.active => { + auth_retried = true; + continue; + } + _ => return Err(e), + } + } + _ => return Err(e), + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use viaduct::ViaductError; + + fn network_error() -> Error { + Error::RequestError(ViaductError::NetworkError("test".into())) + } + + fn auth_error() -> Error { + Error::NoRefreshToken + } + + #[test] + fn default_policy_pinned() { + assert_eq!( + RetryPolicy::DEFAULT, + RetryPolicy { + network_retries: 3, + auth_retry_with_cache_clear: true, + } + ); + } + + #[test] + fn to_state_machine_err_attaches_target_state_for_operational_errors() { + let res: Result<()> = Err(network_error()); + let mapped = res.to_state_machine_err(|| FxaState::AuthIssues); + match mapped { + Err(StateMachineErr::Handled { target, .. }) => { + assert_eq!(target, FxaState::AuthIssues) + } + Err(StateMachineErr::Fatal(_)) => panic!("expected Handled, got Fatal"), + Ok(_) => panic!("expected Err"), + } + } + + #[test] + fn to_state_machine_err_promotes_logic_errors_to_fatal() { + let res: Result<()> = Err(Error::StateMachineLogicError("boom".into())); + let mapped = res.to_state_machine_err(|| FxaState::Disconnected); + match mapped { + Err(StateMachineErr::Fatal(cause)) => { + assert!(matches!(*cause, Error::StateMachineLogicError(_))) + } + Err(StateMachineErr::Handled { .. }) => panic!("expected Fatal, got Handled"), + Ok(_) => panic!("expected Err"), + } + } + + #[test] + fn to_state_machine_err_promotes_invalid_state_transition_to_fatal() { + let res: Result<()> = Err(Error::InvalidStateTransition("nope".into())); + let mapped = res.to_state_machine_err(|| FxaState::Disconnected); + match mapped { + Err(StateMachineErr::Fatal(cause)) => { + assert!(matches!(*cause, Error::InvalidStateTransition(_))) + } + _ => panic!("expected Fatal"), + } + } + + #[test] + fn to_state_machine_err_passes_ok_through() { + let res: Result = Ok(42); + let mapped = res.to_state_machine_err(|| FxaState::Disconnected); + assert_eq!(mapped.unwrap(), 42); + } + + // The retry tests construct a real `FirefoxAccount` because `RetryingAccount` + // requires one structurally; the test closures don't actually touch it. + + fn mock_account() -> FirefoxAccount { + use crate::internal::config::Config; + FirefoxAccount::with_config(Config::new_with_mock_well_known_fxa_client_configuration( + "https://mock-fxa.example.com", + "12345678", + "https://foo.bar", + )) + } + + #[test] + fn with_retry_succeeds_first_try() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result = wrapper.with_retry(|_| { + calls += 1; + Ok::<_, Error>(42) + }); + assert_eq!(result.unwrap(), 42); + assert_eq!(calls, 1); + } + + #[test] + fn with_retry_retries_network_errors_then_succeeds() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result = wrapper.with_retry(|_| { + calls += 1; + if calls <= 2 { + Err(network_error()) + } else { + Ok(7) + } + }); + assert_eq!(result.unwrap(), 7); + assert_eq!(calls, 3); + } + + #[test] + fn with_retry_gives_up_after_network_retry_limit() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result: Result = wrapper.with_retry(|_| { + calls += 1; + Err(network_error()) + }); + assert!(result.is_err()); + assert_eq!(calls, 4); // 1 attempt + 3 retries + } + + #[test] + fn with_retry_does_not_retry_auth_errors() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result: Result = wrapper.with_retry(|_| { + calls += 1; + Err(auth_error()) + }); + assert!(result.is_err()); + assert_eq!(calls, 1); + } + + #[test] + fn with_retry_propagates_logic_errors_immediately() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result: Result = wrapper.with_retry(|_| { + calls += 1; + Err(Error::StateMachineLogicError("boom".into())) + }); + assert!(matches!(result, Err(Error::StateMachineLogicError(_)))); + assert_eq!(calls, 1); + } + + // The auth-recovery path itself can't be exercised here without a real + // backend (it calls `check_authorization_status` against the mock); these + // tests cover the paths that don't trigger recovery. + + #[test] + fn with_auth_recovery_succeeds_first_try() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result = wrapper.with_auth_recovery(|_| { + calls += 1; + Ok::<_, Error>("ok") + }); + assert_eq!(result.unwrap(), "ok"); + assert_eq!(calls, 1); + } + + #[test] + fn with_auth_recovery_retries_network_errors() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result = wrapper.with_auth_recovery(|_| { + calls += 1; + if calls <= 1 { + Err(network_error()) + } else { + Ok("ok") + } + }); + assert_eq!(result.unwrap(), "ok"); + assert_eq!(calls, 2); + } + + #[test] + fn with_auth_recovery_propagates_logic_errors_immediately() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let mut calls = 0; + let result: Result = wrapper.with_auth_recovery(|_| { + calls += 1; + Err(Error::StateMachineLogicError("boom".into())) + }); + assert!(matches!(result, Err(Error::StateMachineLogicError(_)))); + assert_eq!(calls, 1); + } +} diff --git a/components/fxa-client/src/state_machine/internal_machines/auth_issues.rs b/components/fxa-client/src/state_machine/internal_machines/auth_issues.rs deleted file mode 100644 index 2a0778db04..0000000000 --- a/components/fxa-client/src/state_machine/internal_machines/auth_issues.rs +++ /dev/null @@ -1,104 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -use super::{invalid_transition, Event, InternalStateMachine, State}; -use crate::{Error, FxaEvent, FxaRustAuthState, FxaState, Result}; -use error_support::report_error; - -pub struct AuthIssuesStateMachine; - -// Save some typing -use Event::*; -use State::*; - -impl InternalStateMachine for AuthIssuesStateMachine { - fn initial_state(&self, event: FxaEvent) -> Result { - match event { - FxaEvent::BeginOAuthFlow { - service, - scopes, - entrypoint, - } => Ok(BeginOAuthFlow { - service: service.clone(), - scopes: scopes.clone(), - entrypoint: entrypoint.clone(), - initial_state: FxaRustAuthState::AuthIssues, - }), - FxaEvent::Disconnect => Ok(Disconnect), - e => Err(Error::InvalidStateTransition(format!("AuthIssues -> {e}"))), - } - } - - fn next_state(&self, state: State, event: Event) -> Result { - Ok(match (state, event) { - (Disconnect, DisconnectSuccess) => Complete(FxaState::Disconnected), - (Disconnect, CallError) => { - // disconnect() is currently infallible, but let's handle errors anyway in case we - // refactor it in the future. - report_error!("fxa-state-machine-error", "saw CallError after Disconnect"); - Complete(FxaState::Disconnected) - } - (BeginOAuthFlow { .. }, BeginOAuthFlowSuccess { oauth_url }) => { - Complete(FxaState::Authenticating { - oauth_url, - initial_state: FxaRustAuthState::AuthIssues, - }) - } - (BeginOAuthFlow { .. }, CallError) => Cancel, - (state, event) => return invalid_transition(state, event), - }) - } -} - -#[cfg(test)] -mod test { - use super::super::StateMachineTester; - use super::*; - - #[test] - fn test_reauthenticate() { - let tester = StateMachineTester::new( - AuthIssuesStateMachine, - FxaEvent::BeginOAuthFlow { - service: "service".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - }, - ); - - assert_eq!( - tester.state, - BeginOAuthFlow { - service: "service".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - initial_state: FxaRustAuthState::AuthIssues, - } - ); - assert_eq!(tester.peek_next_state(CallError), Cancel); - assert_eq!( - tester.peek_next_state(BeginOAuthFlowSuccess { - oauth_url: "http://example.com/oauth-start".to_owned(), - }), - Complete(FxaState::Authenticating { - oauth_url: "http://example.com/oauth-start".to_owned(), - initial_state: FxaRustAuthState::AuthIssues, - }) - ); - } - - #[test] - fn test_disconnect() { - let tester = StateMachineTester::new(AuthIssuesStateMachine, FxaEvent::Disconnect); - assert_eq!(tester.state, Disconnect); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(DisconnectSuccess), - Complete(FxaState::Disconnected) - ); - } -} diff --git a/components/fxa-client/src/state_machine/internal_machines/authenticating.rs b/components/fxa-client/src/state_machine/internal_machines/authenticating.rs deleted file mode 100644 index 126ddd25ae..0000000000 --- a/components/fxa-client/src/state_machine/internal_machines/authenticating.rs +++ /dev/null @@ -1,290 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -use super::{invalid_transition, Event, InternalStateMachine, State}; -use crate::{Error, FxaEvent, FxaRustAuthState, FxaState, Result}; -use error_support::report_error; - -pub struct AuthenticatingStateMachine { - pub initial_state: FxaRustAuthState, -} - -// Save some typing -use Event::*; -use State::*; - -impl InternalStateMachine for AuthenticatingStateMachine { - fn initial_state(&self, event: FxaEvent) -> Result { - match event { - FxaEvent::CompleteOAuthFlow { code, state } => Ok(CompleteOAuthFlow { - code: code.clone(), - state: state.clone(), - initial_state: self.initial_state, - }), - FxaEvent::CancelOAuthFlow => Ok(Complete(self.initial_state.into())), - FxaEvent::Disconnect => Ok(Disconnect), - // These next 2 cases allow apps to begin a new oauth flow when we're already in the - // middle of an existing one. (Supporting new flows in this state makes sense, but - // allowing multiple active flows, which we kinda do, probably doesn't really work?) - FxaEvent::BeginOAuthFlow { - service, - scopes, - entrypoint, - } => Ok(State::BeginOAuthFlow { - service, - scopes, - entrypoint, - initial_state: self.initial_state, - }), - FxaEvent::BeginPairingFlow { - service, - pairing_url, - scopes, - entrypoint, - } => Ok(State::BeginPairingFlow { - service, - pairing_url, - scopes, - entrypoint, - initial_state: self.initial_state, - }), - e => Err(Error::InvalidStateTransition(format!( - "Authenticating -> {e}" - ))), - } - } - - fn next_state(&self, state: State, event: Event) -> Result { - Ok(match (state, event) { - ( - CompleteOAuthFlow { - initial_state: FxaRustAuthState::Connected, - .. - }, - CompleteOAuthFlowSuccess, - ) => Complete(FxaState::Connected), - (CompleteOAuthFlow { .. }, CompleteOAuthFlowSuccess) => InitializeDevice, - (CompleteOAuthFlow { .. }, CallError) => Complete(self.initial_state.into()), - (Disconnect, DisconnectSuccess) => Complete(FxaState::Disconnected), - (Disconnect, CallError) => { - // disconnect() is currently infallible, but let's handle errors anyway in case we - // refactor it in the future. - report_error!("fxa-state-machine-error", "saw CallError after Disconnect"); - Complete(FxaState::Disconnected) - } - (InitializeDevice, InitializeDeviceSuccess) => Complete(FxaState::Connected), - (InitializeDevice, CallError) => Complete(FxaState::Disconnected), - (BeginOAuthFlow { initial_state, .. }, BeginOAuthFlowSuccess { oauth_url }) => { - Complete(FxaState::Authenticating { - oauth_url, - initial_state, - }) - } - (BeginPairingFlow { initial_state, .. }, BeginPairingFlowSuccess { oauth_url }) => { - Complete(FxaState::Authenticating { - oauth_url, - initial_state, - }) - } - (BeginOAuthFlow { .. }, CallError) => Complete(self.initial_state.into()), - (BeginPairingFlow { .. }, CallError) => Complete(self.initial_state.into()), - (state, event) => return invalid_transition(state, event), - }) - } -} - -#[cfg(test)] -mod test { - use super::super::StateMachineTester; - use super::*; - - #[test] - fn test_complete_oauth_flow() { - let mut tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Disconnected, - }, - FxaEvent::CompleteOAuthFlow { - code: "test-code".to_owned(), - state: "test-state".to_owned(), - }, - ); - assert_eq!( - tester.state, - CompleteOAuthFlow { - code: "test-code".to_owned(), - state: "test-state".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - } - ); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - - tester.next_state(CompleteOAuthFlowSuccess); - assert_eq!(tester.state, InitializeDevice); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(InitializeDeviceSuccess), - Complete(FxaState::Connected) - ); - } - - #[test] - fn test_complete_oauth_flow_connected() { - let mut tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Connected, - }, - FxaEvent::CompleteOAuthFlow { - code: "test-code".to_owned(), - state: "test-state".to_owned(), - }, - ); - assert_eq!( - tester.state, - CompleteOAuthFlow { - code: "test-code".to_owned(), - state: "test-state".to_owned(), - initial_state: FxaRustAuthState::Connected, - } - ); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Connected) - ); - - tester.next_state(CompleteOAuthFlowSuccess); - assert_eq!(tester.state, Complete(FxaState::Connected)); - } - - #[test] - fn test_cancel_oauth_flow() { - let tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Connected, - }, - FxaEvent::CancelOAuthFlow, - ); - assert_eq!(tester.state, Complete(FxaState::Connected)); - - let tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Disconnected, - }, - FxaEvent::CancelOAuthFlow, - ); - assert_eq!(tester.state, Complete(FxaState::Disconnected)); - - let tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::AuthIssues, - }, - FxaEvent::CancelOAuthFlow, - ); - assert_eq!(tester.state, Complete(FxaState::AuthIssues)); - } - - /// Test what happens if we get the `BeginOAuthFlow` when we're already in the middle of - /// authentication. - /// - /// In this case, we should start a new flow. Note: the code to handle the `BeginOAuthFlow` - /// internal state will cancel the previous flow. - #[test] - fn test_begin_oauth_flow() { - let tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Disconnected, - }, - FxaEvent::BeginOAuthFlow { - service: "service".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - }, - ); - assert_eq!( - tester.state, - BeginOAuthFlow { - service: "service".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - } - ); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(BeginOAuthFlowSuccess { - oauth_url: "http://example.com/oauth-start".to_owned(), - }), - Complete(FxaState::Authenticating { - oauth_url: "http://example.com/oauth-start".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - }) - ); - } - - /// Same as `test_begin_oauth_flow`, but for a paring flow - #[test] - fn test_begin_pairing_flow() { - let tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Disconnected, - }, - FxaEvent::BeginPairingFlow { - service: "service".to_owned(), - pairing_url: "https://example.com/pairing-url".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - }, - ); - assert_eq!( - tester.state, - BeginPairingFlow { - service: "service".to_owned(), - pairing_url: "https://example.com/pairing-url".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - } - ); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(BeginPairingFlowSuccess { - oauth_url: "http://example.com/oauth-start".to_owned(), - }), - Complete(FxaState::Authenticating { - oauth_url: "http://example.com/oauth-start".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - }) - ); - } - - #[test] - fn test_disconnect_during_oauth_flow() { - let tester = StateMachineTester::new( - AuthenticatingStateMachine { - initial_state: FxaRustAuthState::Disconnected, - }, - FxaEvent::Disconnect, - ); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(DisconnectSuccess), - Complete(FxaState::Disconnected) - ); - } -} diff --git a/components/fxa-client/src/state_machine/internal_machines/connected.rs b/components/fxa-client/src/state_machine/internal_machines/connected.rs deleted file mode 100644 index d9e3a7910d..0000000000 --- a/components/fxa-client/src/state_machine/internal_machines/connected.rs +++ /dev/null @@ -1,103 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -use super::{invalid_transition, Event, InternalStateMachine, State}; -use crate::{Error, FxaEvent, FxaRustAuthState, FxaState, Result}; -use error_support::report_error; - -pub struct ConnectedStateMachine; - -// Save some typing -use Event::*; -use State::*; - -impl InternalStateMachine for ConnectedStateMachine { - fn initial_state(&self, event: FxaEvent) -> Result { - match event { - FxaEvent::Disconnect => Ok(Disconnect), - FxaEvent::CheckAuthorizationStatus => Ok(CheckAuthorizationStatus), - FxaEvent::CallGetProfile => Ok(GetProfile), - FxaEvent::BeginOAuthFlow { - service, - scopes, - entrypoint, - } => Ok(BeginOAuthFlow { - service, - scopes, - entrypoint, - initial_state: FxaRustAuthState::Connected, - }), - e => Err(Error::InvalidStateTransition(format!("Connected -> {e}"))), - } - } - - fn next_state(&self, state: State, event: Event) -> Result { - Ok(match (state, event) { - (Disconnect, DisconnectSuccess) => Complete(FxaState::Disconnected), - (Disconnect, CallError) => { - // disconnect() is currently infallible, but let's handle errors anyway in case we - // refactor it in the future. - report_error!("fxa-state-machine-error", "saw CallError after Disconnect"); - Complete(FxaState::Disconnected) - } - (CheckAuthorizationStatus, CheckAuthorizationStatusSuccess { active }) => { - if active { - Complete(FxaState::Connected) - } else { - Complete(FxaState::AuthIssues) - } - } - (GetProfile, GetProfileSuccess) => Complete(FxaState::Connected), - (GetProfile, CallError) => Complete(FxaState::AuthIssues), - (CheckAuthorizationStatus, CallError) => Complete(FxaState::AuthIssues), - (BeginOAuthFlow { initial_state, .. }, BeginOAuthFlowSuccess { oauth_url }) => { - Complete(FxaState::Authenticating { - oauth_url, - initial_state, - }) - } - (BeginOAuthFlow { .. }, CallError) => Cancel, - (state, event) => return invalid_transition(state, event), - }) - } -} - -#[cfg(test)] -mod test { - use super::super::StateMachineTester; - use super::*; - - #[test] - fn test_disconnect() { - let tester = StateMachineTester::new(ConnectedStateMachine, FxaEvent::Disconnect); - assert_eq!(tester.state, Disconnect); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(DisconnectSuccess), - Complete(FxaState::Disconnected) - ); - } - - #[test] - fn test_check_authorization() { - let tester = - StateMachineTester::new(ConnectedStateMachine, FxaEvent::CheckAuthorizationStatus); - assert_eq!(tester.state, CheckAuthorizationStatus); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::AuthIssues) - ); - assert_eq!( - tester.peek_next_state(CheckAuthorizationStatusSuccess { active: true }), - Complete(FxaState::Connected), - ); - assert_eq!( - tester.peek_next_state(CheckAuthorizationStatusSuccess { active: false }), - Complete(FxaState::AuthIssues) - ); - } -} diff --git a/components/fxa-client/src/state_machine/internal_machines/disconnected.rs b/components/fxa-client/src/state_machine/internal_machines/disconnected.rs deleted file mode 100644 index 2555e3e80b..0000000000 --- a/components/fxa-client/src/state_machine/internal_machines/disconnected.rs +++ /dev/null @@ -1,134 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -use super::{invalid_transition, Event, InternalStateMachine, State}; -use crate::{Error, FxaEvent, FxaRustAuthState, FxaState, Result}; - -pub struct DisconnectedStateMachine; - -// Save some typing -use Event::*; -use State::*; - -impl InternalStateMachine for DisconnectedStateMachine { - fn initial_state(&self, event: FxaEvent) -> Result { - match event { - FxaEvent::BeginOAuthFlow { - service, - scopes, - entrypoint, - } => Ok(State::BeginOAuthFlow { - service, - scopes, - entrypoint, - initial_state: FxaRustAuthState::Disconnected, - }), - FxaEvent::BeginPairingFlow { - pairing_url, - service, - scopes, - entrypoint, - } => Ok(State::BeginPairingFlow { - pairing_url, - service, - scopes, - entrypoint, - initial_state: FxaRustAuthState::Disconnected, - }), - e => Err(Error::InvalidStateTransition(format!( - "Disconnected -> {e}" - ))), - } - } - - fn next_state(&self, state: State, event: Event) -> Result { - Ok(match (state, event) { - (BeginOAuthFlow { .. }, BeginOAuthFlowSuccess { oauth_url }) => { - Complete(FxaState::Authenticating { - oauth_url, - initial_state: FxaRustAuthState::Disconnected, - }) - } - (BeginPairingFlow { .. }, BeginPairingFlowSuccess { oauth_url }) => { - Complete(FxaState::Authenticating { - oauth_url, - initial_state: FxaRustAuthState::Disconnected, - }) - } - (BeginOAuthFlow { .. }, CallError) => Cancel, - (BeginPairingFlow { .. }, CallError) => Cancel, - (state, event) => return invalid_transition(state, event), - }) - } -} - -#[cfg(test)] -mod test { - use super::super::StateMachineTester; - use super::*; - - #[test] - fn test_oauth_flow() { - let tester = StateMachineTester::new( - DisconnectedStateMachine, - FxaEvent::BeginOAuthFlow { - service: "service".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - }, - ); - assert_eq!( - tester.state, - BeginOAuthFlow { - service: "service".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - } - ); - assert_eq!(tester.peek_next_state(CallError), Cancel); - assert_eq!( - tester.peek_next_state(BeginOAuthFlowSuccess { - oauth_url: "http://example.com/oauth-start".to_owned(), - }), - Complete(FxaState::Authenticating { - oauth_url: "http://example.com/oauth-start".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - }) - ); - } - - #[test] - fn test_pairing_flow() { - let tester = StateMachineTester::new( - DisconnectedStateMachine, - FxaEvent::BeginPairingFlow { - service: "service".to_owned(), - pairing_url: "https://example.com/pairing-url".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - }, - ); - assert_eq!( - tester.state, - BeginPairingFlow { - service: "service".to_owned(), - pairing_url: "https://example.com/pairing-url".to_owned(), - scopes: vec!["profile".to_owned()], - entrypoint: "test-entrypoint".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - } - ); - assert_eq!(tester.peek_next_state(CallError), Cancel); - assert_eq!( - tester.peek_next_state(BeginPairingFlowSuccess { - oauth_url: "http://example.com/oauth-start".to_owned(), - }), - Complete(FxaState::Authenticating { - oauth_url: "http://example.com/oauth-start".to_owned(), - initial_state: FxaRustAuthState::Disconnected, - }) - ); - } -} diff --git a/components/fxa-client/src/state_machine/internal_machines/mod.rs b/components/fxa-client/src/state_machine/internal_machines/mod.rs deleted file mode 100644 index 8eedff237a..0000000000 --- a/components/fxa-client/src/state_machine/internal_machines/mod.rs +++ /dev/null @@ -1,308 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -//! Internal state machine code - -mod auth_issues; -mod authenticating; -mod connected; -mod disconnected; -mod uninitialized; - -use crate::{ - internal::FirefoxAccount, DeviceConfig, Error, FxaError, FxaEvent, FxaRustAuthState, FxaState, - Result, -}; -pub use auth_issues::AuthIssuesStateMachine; -pub use authenticating::AuthenticatingStateMachine; -pub use connected::ConnectedStateMachine; -pub use disconnected::DisconnectedStateMachine; -use error_support::convert_log_report_error; -pub use uninitialized::UninitializedStateMachine; - -pub trait InternalStateMachine { - /// Initial state to start handling an public event - fn initial_state(&self, event: FxaEvent) -> Result; - - /// State transition from an internal event - fn next_state(&self, state: State, event: Event) -> Result; -} - -/// Internal state machine states -/// -/// Most variants either represent a [FirefoxAccount] method call. -/// `Complete` and `Cancel` are a terminal states which indicate the public state transition is complete. -/// Each internal state machine uses the same `State` enum, but they only actually transition to a subset of the variants. -#[derive(Clone, Debug, PartialEq, Eq)] -#[allow(clippy::enum_variant_names)] -pub enum State { - GetAuthState, - BeginOAuthFlow { - service: String, - scopes: Vec, - entrypoint: String, - // The auth state of the account when beginning the flow. - initial_state: FxaRustAuthState, - }, - BeginPairingFlow { - service: String, - pairing_url: String, - scopes: Vec, - entrypoint: String, - // The auth state of the account when beginning the flow. - initial_state: FxaRustAuthState, - }, - CompleteOAuthFlow { - code: String, - state: String, - initial_state: FxaRustAuthState, - }, - InitializeDevice, - EnsureDeviceCapabilities, - CheckAuthorizationStatus, - Disconnect, - GetProfile, - /// Complete the current [FxaState] transition by transitioning to a new state - Complete(FxaState), - /// Complete the current [FxaState] transition by remaining at the current state - Cancel, -} - -/// Internal state machine events -/// -/// These represent the results of the method calls for each internal state. -/// Each internal state machine uses the same `Event` enum, but they only actually respond to a subset of the variants. -#[derive(Clone, Debug)] -pub enum Event { - GetAuthStateSuccess { - auth_state: FxaRustAuthState, - }, - BeginOAuthFlowSuccess { - oauth_url: String, - }, - BeginPairingFlowSuccess { - oauth_url: String, - }, - CompleteOAuthFlowSuccess, - InitializeDeviceSuccess, - EnsureDeviceCapabilitiesSuccess, - CheckAuthorizationStatusSuccess { - active: bool, - }, - DisconnectSuccess, - GetProfileSuccess, - CallError, - /// Auth error for the `ensure_capabilities` call that we do on startup. - /// This should likely go away when we do https://bugzilla.mozilla.org/show_bug.cgi?id=1868418 - EnsureCapabilitiesAuthError, -} - -impl State { - /// Perform the [FirefoxAccount] method call that corresponds to this state - pub fn make_call( - &self, - account: &mut FirefoxAccount, - device_config: &DeviceConfig, - ) -> Result { - let mut error_handling = CallErrorHandler::new(self); - loop { - return match self.make_call_inner(account, device_config) { - Ok(event) => Ok(event), - Err(e) => match error_handling.handle_error(e, account) { - CallResult::Retry => continue, - CallResult::Finished(event) => Ok(event), - CallResult::InternalError(err) => Err(err), - }, - }; - } - } - - fn make_call_inner( - &self, - account: &mut FirefoxAccount, - device_config: &DeviceConfig, - ) -> Result { - Ok(match self { - State::GetAuthState => Event::GetAuthStateSuccess { - auth_state: account.get_auth_state(), - }, - State::EnsureDeviceCapabilities => { - account.ensure_capabilities(&device_config.capabilities)?; - Event::EnsureDeviceCapabilitiesSuccess - } - State::BeginOAuthFlow { - service, - scopes, - entrypoint, - .. - } => { - account.cancel_existing_oauth_flows(); - let scopes: Vec<&str> = scopes.iter().map(String::as_str).collect(); - let oauth_url = account.begin_oauth_flow(service, &scopes, entrypoint)?; - Event::BeginOAuthFlowSuccess { oauth_url } - } - State::BeginPairingFlow { - service, - pairing_url, - scopes, - entrypoint, - .. - } => { - account.cancel_existing_oauth_flows(); - let scopes: Vec<&str> = scopes.iter().map(String::as_str).collect(); - let oauth_url = - account.begin_pairing_flow(pairing_url, service, &scopes, entrypoint)?; - Event::BeginPairingFlowSuccess { oauth_url } - } - State::CompleteOAuthFlow { code, state, .. } => { - account.complete_oauth_flow(code, state)?; - Event::CompleteOAuthFlowSuccess - } - State::InitializeDevice => { - account.initialize_device( - &device_config.name, - device_config.device_type, - &device_config.capabilities, - )?; - Event::InitializeDeviceSuccess - } - State::CheckAuthorizationStatus => { - let active = account.check_authorization_status()?.active; - Event::CheckAuthorizationStatusSuccess { active } - } - State::Disconnect => { - account.disconnect(); - Event::DisconnectSuccess - } - State::GetProfile => { - account.get_profile(true)?; - Event::GetProfileSuccess - } - state => { - return Err(Error::StateMachineLogicError(format!( - "process_call: Don't know how to handle {state}" - ))) - } - }) - } -} - -/// Number of times to retry fxa calls in the face of network errors -const NETWORK_RETRY_LIMIT: usize = 3; - -struct CallErrorHandler<'a> { - network_retries: usize, - auth_retries: usize, - state: &'a State, -} - -impl<'a> CallErrorHandler<'a> { - fn new(state: &'a State) -> Self { - Self { - network_retries: 0, - auth_retries: 0, - state, - } - } - - fn handle_error(&mut self, e: Error, account: &mut FirefoxAccount) -> CallResult { - // If we see a StateMachineLogicError, return it immediately - if matches!(e, Error::StateMachineLogicError(_)) { - return CallResult::InternalError(e); - } - // Report the error and convert it to `FxaError` which makes it easier to handle. - // For example, multiple `Error` variants map to `FxaError::Authentication`. - crate::warn!("handling error: {e}"); - match convert_log_report_error(e) { - FxaError::Network => { - if self.network_retries < NETWORK_RETRY_LIMIT { - self.network_retries += 1; - CallResult::Retry - } else { - CallResult::Finished(Event::CallError) - } - } - FxaError::Authentication => { - if self.auth_retries < 1 && !matches!(self.state, State::CheckAuthorizationStatus) { - // Operations can fail with authentication errors when we have stale access - // token in our cache. To try to recover from this we should: - // - // - Clear the access token - // - Call `check_authorization_status`. If successful we can retry the operation. - account.clear_access_token_cache(); - match account.check_authorization_status() { - Ok(status) if status.active => { - self.auth_retries += 1; - CallResult::Retry - } - _ => CallResult::Finished(self.event_for_auth_error()), - } - } else { - CallResult::Finished(self.event_for_auth_error()) - } - } - _ => CallResult::Finished(Event::CallError), - } - } - - fn event_for_auth_error(&self) -> Event { - if matches!(self.state, State::EnsureDeviceCapabilities) { - Event::EnsureCapabilitiesAuthError - } else { - Event::CallError - } - } -} - -/// The result of a single call to the FxA client -enum CallResult { - /// The call finished, either successfully or unsuccessfully, and we have a new [Event] to - /// process. - Finished(Event), - /// We should to retry the call after an auth/network error. - Retry, - /// There was an internal error when trying to make the call and we should bail on the internal - /// state transition. - InternalError(Error), -} - -fn invalid_transition(state: State, event: Event) -> Result { - Err(Error::InvalidStateTransition(format!("{state} -> {event}"))) -} - -#[cfg(test)] -struct StateMachineTester { - state_machine: T, - state: State, -} - -#[cfg(test)] -impl StateMachineTester { - fn new(state_machine: T, event: FxaEvent) -> Self { - let initial_state = state_machine - .initial_state(event) - .expect("Error getting initial state"); - Self { - state_machine, - state: initial_state, - } - } - - /// Transition to a new state based on an event - fn next_state(&mut self, event: Event) { - self.state = self.peek_next_state(event); - } - - /// peek_next_state what the next state would be without transitioning to it - fn peek_next_state(&self, event: Event) -> State { - self.state_machine - .next_state(self.state.clone(), event.clone()) - .unwrap_or_else(|e| { - panic!( - "Error getting next state: {e} state: {:?} event: {event:?}", - self.state - ) - }) - } -} diff --git a/components/fxa-client/src/state_machine/internal_machines/uninitialized.rs b/components/fxa-client/src/state_machine/internal_machines/uninitialized.rs deleted file mode 100644 index ba99d2f856..0000000000 --- a/components/fxa-client/src/state_machine/internal_machines/uninitialized.rs +++ /dev/null @@ -1,110 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -use super::{invalid_transition, Event, InternalStateMachine, State}; -use crate::{Error, FxaEvent, FxaRustAuthState, FxaState, Result}; - -pub struct UninitializedStateMachine; - -// Save some typing -use Event::*; -use State::*; - -impl InternalStateMachine for UninitializedStateMachine { - fn initial_state(&self, event: FxaEvent) -> Result { - match event { - FxaEvent::Initialize { .. } => Ok(GetAuthState), - e => Err(Error::InvalidStateTransition(format!( - "Uninitialized -> {e}" - ))), - } - } - - fn next_state(&self, state: State, event: Event) -> Result { - Ok(match (state, event) { - (GetAuthState, GetAuthStateSuccess { auth_state }) => match auth_state { - FxaRustAuthState::Disconnected => Complete(FxaState::Disconnected), - FxaRustAuthState::AuthIssues => Complete(FxaState::AuthIssues), - FxaRustAuthState::Connected => EnsureDeviceCapabilities, - }, - (EnsureDeviceCapabilities, EnsureDeviceCapabilitiesSuccess) => { - Complete(FxaState::Connected) - } - (EnsureDeviceCapabilities, CallError) => Complete(FxaState::Disconnected), - (EnsureDeviceCapabilities, EnsureCapabilitiesAuthError) => CheckAuthorizationStatus, - - // FIXME: we should re-run `ensure_capabilities` in this case, but we don't in order to - // match the current firefox-android behavior. - // See https://bugzilla.mozilla.org/show_bug.cgi?id=1868418 - (CheckAuthorizationStatus, CheckAuthorizationStatusSuccess { active: true }) => { - Complete(FxaState::Connected) - } - (CheckAuthorizationStatus, CheckAuthorizationStatusSuccess { active: false }) - | (CheckAuthorizationStatus, CallError) => Complete(FxaState::AuthIssues), - (state, event) => return invalid_transition(state, event), - }) - } -} - -#[cfg(test)] -mod test { - use super::super::StateMachineTester; - use super::*; - use crate::{DeviceConfig, DeviceType}; - - #[test] - fn test_state_machine() { - let mut tester = StateMachineTester::new( - UninitializedStateMachine, - FxaEvent::Initialize { - device_config: DeviceConfig { - name: "test-device".to_owned(), - device_type: DeviceType::Mobile, - capabilities: vec![], - }, - }, - ); - assert_eq!(tester.state, GetAuthState); - assert_eq!( - tester.peek_next_state(GetAuthStateSuccess { - auth_state: FxaRustAuthState::Disconnected - }), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(GetAuthStateSuccess { - auth_state: FxaRustAuthState::AuthIssues - }), - Complete(FxaState::AuthIssues) - ); - - tester.next_state(GetAuthStateSuccess { - auth_state: FxaRustAuthState::Connected, - }); - assert_eq!(tester.state, EnsureDeviceCapabilities); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::Disconnected) - ); - assert_eq!( - tester.peek_next_state(EnsureDeviceCapabilitiesSuccess), - Complete(FxaState::Connected) - ); - - tester.next_state(EnsureCapabilitiesAuthError); - assert_eq!(tester.state, CheckAuthorizationStatus); - assert_eq!( - tester.peek_next_state(CallError), - Complete(FxaState::AuthIssues) - ); - assert_eq!( - tester.peek_next_state(CheckAuthorizationStatusSuccess { active: false }), - Complete(FxaState::AuthIssues) - ); - assert_eq!( - tester.peek_next_state(CheckAuthorizationStatusSuccess { active: true }), - Complete(FxaState::Connected) - ); - } -} diff --git a/components/fxa-client/src/state_machine/mod.rs b/components/fxa-client/src/state_machine/mod.rs index b24970607d..48e947b3cf 100644 --- a/components/fxa-client/src/state_machine/mod.rs +++ b/components/fxa-client/src/state_machine/mod.rs @@ -6,19 +6,15 @@ //! //! This presents a high-level API for logging in, logging out, dealing with authentication token issues, etc. -use error_support::breadcrumb; +use error_support::{breadcrumb, convert_log_report_error}; -use crate::{internal::FirefoxAccount, DeviceConfig, Error, FxaEvent, FxaState, Result}; +use crate::{internal::FirefoxAccount, Error, FxaEvent, FxaState, Result}; mod display; -mod internal_machines; +mod helpers; +mod transitions; -/// Number of state transitions to perform before giving up and assuming the internal state machine -/// is stuck in an infinite loop -const MAX_INTERNAL_TRANSITIONS: usize = 20; - -use internal_machines::InternalStateMachine; -use internal_machines::State as InternalState; +use helpers::{RetryingAccount, StateMachineErr}; impl FirefoxAccount { /// Get the current state @@ -31,98 +27,53 @@ impl FirefoxAccount { /// On success, returns the new state. /// On error, the state will remain the same. pub fn process_event(&mut self, event: FxaEvent) -> Result { - let was_in_auth_issues = matches!(self.auth_state, FxaState::AuthIssues); - - let next_state = match &self.auth_state { - FxaState::Uninitialized => self.process_event_with_internal_state_machine( - internal_machines::UninitializedStateMachine, - event, - )?, - FxaState::Disconnected => self.process_event_with_internal_state_machine( - internal_machines::DisconnectedStateMachine, - event, - )?, - FxaState::Authenticating { initial_state, .. } => self - .process_event_with_internal_state_machine( - internal_machines::AuthenticatingStateMachine { - initial_state: *initial_state, - }, - event, - )?, - FxaState::Connected => self.process_event_with_internal_state_machine( - internal_machines::ConnectedStateMachine, - event, - )?, - FxaState::AuthIssues => self.process_event_with_internal_state_machine( - internal_machines::AuthIssuesStateMachine, - event, - )?, - }; - if !was_in_auth_issues && matches!(next_state, FxaState::AuthIssues) { - self.on_auth_issues(); - } - Ok(next_state) - } + // Must run before transition() — side effects read `device_config`. + self.handle_state_machine_initialization(&event)?; - fn process_event_with_internal_state_machine( - &mut self, - state_machine: T, - event: FxaEvent, - ) -> Result { - let device_config = self.handle_state_machine_initialization(&event)?; + let was_in_auth_issues = matches!(self.auth_state, FxaState::AuthIssues); + let from_state = self.auth_state.clone(); breadcrumb!("FxaStateMachine.process_event starting: {event}"); - let mut internal_state = state_machine.initial_state(event)?; - let mut count = 0; - // Loop through internal state transitions until we reach a terminal state - // - // See `README.md` for details. - loop { - count += 1; - if count > MAX_INTERNAL_TRANSITIONS { - breadcrumb!("FxaStateMachine.process_event finished (MAX_INTERNAL_TRANSITIONS)"); - return Err(Error::StateMachineLogicError( - "infinite loop detected".to_owned(), - )); + + let mut retrying = RetryingAccount::new(self); + let new_state = match transitions::transition(&mut retrying, from_state, event) { + Ok(s) => { + breadcrumb!("FxaStateMachine.process_event finished (Done({s}))"); + s } - match internal_state { - InternalState::Complete(new_state) => { - breadcrumb!("FxaStateMachine.process_event finished (Complete({new_state}))"); - self.auth_state = new_state.clone(); - return Ok(new_state); - } - InternalState::Cancel => { - breadcrumb!("FxaStateMachine.process_event finished (Cancel)"); - return Ok(self.auth_state.clone()); - } - state => { - let event = state.make_call(self, &device_config)?; - let event_msg = event.to_string(); - let next_state = state_machine.next_state(state, event); - breadcrumb!("FxaStateMachine.process_event {event_msg} -> {next_state:?}"); - internal_state = next_state?; - } + Err(StateMachineErr::Handled { cause, target }) => { + breadcrumb!("FxaStateMachine.process_event finished (handled -> {target})"); + let _ = convert_log_report_error(*cause); + target + } + Err(StateMachineErr::Fatal(cause)) => { + breadcrumb!("FxaStateMachine.process_event finished (fatal — state unchanged)"); + return Err(*cause); } + }; + + self.auth_state = new_state.clone(); + if !was_in_auth_issues && matches!(new_state, FxaState::AuthIssues) { + self.on_auth_issues(); } + Ok(new_state) } - /// Handles initialization before we process an event - /// - /// This checks that the first event we see is `FxaEvent::Initialize` and it returns the - /// `DeviceConfig` from that event. - fn handle_state_machine_initialization(&mut self, event: &FxaEvent) -> Result { - match &event { + /// Seeds `device_config` from the first `Initialize`; rejects other events + /// before that and a second `Initialize` afterwards. + fn handle_state_machine_initialization(&mut self, event: &FxaEvent) -> Result<()> { + match event { FxaEvent::Initialize { device_config } => match self.device_config { Some(_) => Err(Error::InvalidStateTransition( "Initialize already sent".to_owned(), )), None => { self.device_config = Some(device_config.clone()); - Ok(device_config.clone()) + Ok(()) } }, _ => match &self.device_config { - Some(device_config) => Ok(device_config.clone()), + Some(_) => Ok(()), None => Err(Error::InvalidStateTransition( "Initialize not yet sent".to_owned(), )), @@ -130,3 +81,145 @@ impl FirefoxAccount { } } } + +#[cfg(test)] +mod driver_tests { + //! End-to-end `process_event` tests covering the init gate, fatal errors + //! leaving state unchanged, and successful transitions committing it. + use crate::{ + internal::{config::Config, FirefoxAccount}, + DeviceCapability, DeviceConfig, DeviceType, Error, FxaEvent, FxaState, + }; + + fn mock_account() -> FirefoxAccount { + FirefoxAccount::with_config(Config::new_with_mock_well_known_fxa_client_configuration( + "https://mock-fxa.example.com", + "12345678", + "https://foo.bar", + )) + } + + fn device_config() -> DeviceConfig { + DeviceConfig { + name: "test-device".to_owned(), + device_type: DeviceType::Mobile, + capabilities: vec![DeviceCapability::SendTab], + } + } + + #[test] + fn process_event_initialize_from_uninitialized_lands_at_disconnected() { + // Fresh mock account has no refresh token, so it lands at Disconnected. + nss::ensure_initialized(); + let mut account = mock_account(); + assert_eq!(account.get_state(), FxaState::Uninitialized); + + let result = account.process_event(FxaEvent::Initialize { + device_config: device_config(), + }); + + assert_eq!(result.unwrap(), FxaState::Disconnected); + assert_eq!(account.get_state(), FxaState::Disconnected); + } + + #[test] + fn process_event_initialize_twice_returns_err_unchanged_state() { + nss::ensure_initialized(); + let mut account = mock_account(); + let _ = account + .process_event(FxaEvent::Initialize { + device_config: device_config(), + }) + .unwrap(); + let state_before = account.get_state(); + + let result = account.process_event(FxaEvent::Initialize { + device_config: device_config(), + }); + + match result { + Err(Error::InvalidStateTransition(_)) => {} + other => panic!("expected InvalidStateTransition, got {other:?}"), + } + assert_eq!(account.get_state(), state_before); + } + + #[test] + fn process_event_non_initialize_before_initialize_returns_err_unchanged_state() { + nss::ensure_initialized(); + let mut account = mock_account(); + assert_eq!(account.get_state(), FxaState::Uninitialized); + + let result = account.process_event(FxaEvent::Disconnect); + + match result { + Err(Error::InvalidStateTransition(_)) => {} + other => panic!("expected InvalidStateTransition, got {other:?}"), + } + assert_eq!(account.get_state(), FxaState::Uninitialized); + } + + #[test] + fn process_event_invalid_state_event_pair_returns_err_unchanged_state() { + nss::ensure_initialized(); + let mut account = mock_account(); + let _ = account + .process_event(FxaEvent::Initialize { + device_config: device_config(), + }) + .unwrap(); + assert_eq!(account.get_state(), FxaState::Disconnected); + + let result = account.process_event(FxaEvent::Disconnect); + + match result { + Err(Error::InvalidStateTransition(_)) => {} + other => panic!("expected InvalidStateTransition, got {other:?}"), + } + assert_eq!(account.get_state(), FxaState::Disconnected); + } + + #[test] + fn process_event_connected_initialize_returns_err_unchanged_state() { + nss::ensure_initialized(); + let mut account = mock_account(); + let _ = account + .process_event(FxaEvent::Initialize { + device_config: device_config(), + }) + .unwrap(); + // Force Connected without going through OAuth. + account.auth_state = FxaState::Connected; + + let result = account.process_event(FxaEvent::Initialize { + device_config: device_config(), + }); + + match result { + Err(Error::InvalidStateTransition(_)) => {} + other => panic!("expected InvalidStateTransition, got {other:?}"), + } + assert_eq!(account.get_state(), FxaState::Connected); + } + + #[test] + fn process_event_authenticating_cancel_oauth_returns_to_initial_state() { + // initial_state must round-trip through CancelOAuthFlow. + nss::ensure_initialized(); + let mut account = mock_account(); + let _ = account + .process_event(FxaEvent::Initialize { + device_config: device_config(), + }) + .unwrap(); + account.auth_state = FxaState::Authenticating { + oauth_url: "https://example.com/oauth".to_owned(), + initial_state: crate::FxaRustAuthState::AuthIssues, + }; + + let result = account.process_event(FxaEvent::CancelOAuthFlow); + + assert_eq!(result.unwrap(), FxaState::AuthIssues); + assert_eq!(account.get_state(), FxaState::AuthIssues); + } +} diff --git a/components/fxa-client/src/state_machine/transitions.rs b/components/fxa-client/src/state_machine/transitions.rs new file mode 100644 index 0000000000..29e68f9412 --- /dev/null +++ b/components/fxa-client/src/state_machine/transitions.rs @@ -0,0 +1,308 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! FxA state machine transition function. +//! +//! Each `match` arm reads top-to-bottom as imperative Rust. Use +//! `.to_state_machine_err(|| target)?` to attach the landing state on failure. + +use crate::{Error, FxaError, FxaEvent, FxaRustAuthState, FxaState}; +use error_support::{convert_log_report_error, GetErrorHandling}; + +use super::helpers::{ResultExt, RetryingAccount, StateMachineErr}; + +/// Transition the FSM from `from` given `event`. +pub fn transition( + account: &mut RetryingAccount<'_>, + from: FxaState, + event: FxaEvent, +) -> std::result::Result { + use FxaState as S; + + match (from, event) { + // ── From Uninitialized ────────────────────────────────────────── + (S::Uninitialized, FxaEvent::Initialize { device_config }) => { + match account.get_auth_state() { + FxaRustAuthState::Disconnected => Ok(S::Disconnected), + FxaRustAuthState::AuthIssues => Ok(S::AuthIssues), + FxaRustAuthState::Connected => { + // Auth errors from ensure_capabilities recover via CheckAuthorizationStatus + // rather than bailing to Disconnected. + // FIXME: should re-run ensure_capabilities after recovery succeeds. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1868418 + match account.ensure_capabilities(&device_config.capabilities) { + Ok(_) => Ok(S::Connected), + Err(e) if is_auth_error(&e) => { + // Report inline since we don't propagate this error to the driver. + let _: FxaError = convert_log_report_error(e); + let active = account + .check_authorization_status() + .to_state_machine_err(|| S::AuthIssues)?; + Ok(if active { S::Connected } else { S::AuthIssues }) + } + Err(cause) => Err(StateMachineErr::new(cause, S::Disconnected)), + } + } + } + } + + // ── From Disconnected ─────────────────────────────────────────── + ( + S::Disconnected, + FxaEvent::BeginOAuthFlow { + service, + scopes, + entrypoint, + }, + ) => { + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + let oauth_url = account + .begin_oauth_flow(&service, &scope_refs, &entrypoint) + .to_state_machine_err(|| S::Disconnected)?; + Ok(S::Authenticating { + oauth_url, + initial_state: FxaRustAuthState::Disconnected, + }) + } + ( + S::Disconnected, + FxaEvent::BeginPairingFlow { + pairing_url, + service, + scopes, + entrypoint, + }, + ) => { + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + let oauth_url = account + .begin_pairing_flow(&pairing_url, &service, &scope_refs, &entrypoint) + .to_state_machine_err(|| S::Disconnected)?; + Ok(S::Authenticating { + oauth_url, + initial_state: FxaRustAuthState::Disconnected, + }) + } + + // ── From Authenticating ───────────────────────────────────────── + (S::Authenticating { initial_state, .. }, FxaEvent::CompleteOAuthFlow { code, state }) => { + account + .complete_oauth_flow(&code, &state) + .to_state_machine_err(|| initial_state.into())?; + + // Initial state was Connected: device is already initialized, skip the call. + if !matches!(initial_state, FxaRustAuthState::Connected) { + let dc = account.device_config().clone(); + account + .initialize_device(&dc.name, dc.device_type, &dc.capabilities) + .to_state_machine_err(|| FxaState::Disconnected)?; + } + + Ok(S::Connected) + } + (S::Authenticating { initial_state, .. }, FxaEvent::CancelOAuthFlow) => { + Ok(initial_state.into()) + } + (S::Authenticating { .. }, FxaEvent::Disconnect) => { + account.disconnect(); + Ok(S::Disconnected) + } + ( + S::Authenticating { initial_state, .. }, + FxaEvent::BeginOAuthFlow { + service, + scopes, + entrypoint, + }, + ) => { + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + let oauth_url = account + .begin_oauth_flow(&service, &scope_refs, &entrypoint) + .to_state_machine_err(|| initial_state.into())?; + Ok(S::Authenticating { + oauth_url, + initial_state, + }) + } + ( + S::Authenticating { initial_state, .. }, + FxaEvent::BeginPairingFlow { + pairing_url, + service, + scopes, + entrypoint, + }, + ) => { + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + let oauth_url = account + .begin_pairing_flow(&pairing_url, &service, &scope_refs, &entrypoint) + .to_state_machine_err(|| initial_state.into())?; + Ok(S::Authenticating { + oauth_url, + initial_state, + }) + } + + // ── From Connected ────────────────────────────────────────────── + (S::Connected, FxaEvent::Disconnect) => { + account.disconnect(); + Ok(S::Disconnected) + } + (S::Connected, FxaEvent::CheckAuthorizationStatus) => { + let active = account + .check_authorization_status() + .to_state_machine_err(|| S::AuthIssues)?; + Ok(if active { S::Connected } else { S::AuthIssues }) + } + (S::Connected, FxaEvent::CallGetProfile) => { + account + .get_profile() + .to_state_machine_err(|| S::AuthIssues)?; + Ok(S::Connected) + } + ( + S::Connected, + FxaEvent::BeginOAuthFlow { + service, + scopes, + entrypoint, + }, + ) => { + // OAuth flow from a connected user (e.g. authorizing additional scopes). + // Lands back at Connected after CompleteOAuthFlow since the device is + // already initialized. + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + let oauth_url = account + .begin_oauth_flow(&service, &scope_refs, &entrypoint) + .to_state_machine_err(|| S::Connected)?; + Ok(S::Authenticating { + oauth_url, + initial_state: FxaRustAuthState::Connected, + }) + } + + // ── From AuthIssues ───────────────────────────────────────────── + ( + S::AuthIssues, + FxaEvent::BeginOAuthFlow { + service, + scopes, + entrypoint, + }, + ) => { + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + let oauth_url = account + .begin_oauth_flow(&service, &scope_refs, &entrypoint) + .to_state_machine_err(|| S::AuthIssues)?; + Ok(S::Authenticating { + oauth_url, + initial_state: FxaRustAuthState::AuthIssues, + }) + } + (S::AuthIssues, FxaEvent::Disconnect) => { + account.disconnect(); + Ok(S::Disconnected) + } + + // ── Invalid (state, event) pair ───────────────────────────────── + (state, event) => Err(StateMachineErr::Fatal(Box::new( + Error::InvalidStateTransition(format!("{state} -> {event}")), + ))), + } +} + +fn is_auth_error(e: &Error) -> bool { + matches!(e.get_error_handling().err, FxaError::Authentication) +} + +#[cfg(test)] +mod tests { + //! Tests for the I/O-free transition arms (cancel-oauth, invalid combos). + use super::*; + use crate::{DeviceConfig, DeviceType}; + + fn mock_account() -> crate::internal::FirefoxAccount { + use crate::internal::config::Config; + crate::internal::FirefoxAccount::with_config( + Config::new_with_mock_well_known_fxa_client_configuration( + "https://mock-fxa.example.com", + "12345678", + "https://foo.bar", + ), + ) + } + + fn authenticating_from(initial_state: FxaRustAuthState) -> FxaState { + FxaState::Authenticating { + oauth_url: "https://example.com/oauth".to_owned(), + initial_state, + } + } + + #[test] + fn cancel_oauth_returns_to_initial_state() { + nss::ensure_initialized(); + for (initial, expected) in [ + (FxaRustAuthState::Disconnected, FxaState::Disconnected), + (FxaRustAuthState::AuthIssues, FxaState::AuthIssues), + (FxaRustAuthState::Connected, FxaState::Connected), + ] { + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let result = transition( + &mut wrapper, + authenticating_from(initial), + FxaEvent::CancelOAuthFlow, + ); + assert_eq!(result.unwrap(), expected); + } + } + + fn assert_fatal_invalid_transition(result: std::result::Result) { + match result { + Err(StateMachineErr::Fatal(cause)) => { + assert!(matches!(*cause, Error::InvalidStateTransition(_))); + } + Err(StateMachineErr::Handled { .. }) => { + panic!("expected Fatal(InvalidStateTransition), got Handled") + } + Ok(s) => panic!("expected InvalidStateTransition, got Ok({s:?})"), + } + } + + #[test] + fn invalid_state_event_pair_returns_fatal_invalid_state_transition() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let device_config = DeviceConfig { + name: "test-device".to_owned(), + device_type: DeviceType::Mobile, + capabilities: vec![], + }; + let result = transition( + &mut wrapper, + FxaState::Connected, + FxaEvent::Initialize { device_config }, + ); + assert_fatal_invalid_transition(result); + } + + #[test] + fn uninitialized_disconnect_invalid() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let result = transition(&mut wrapper, FxaState::Uninitialized, FxaEvent::Disconnect); + assert_fatal_invalid_transition(result); + } + + #[test] + fn disconnected_invalid_event_returns_fatal_invalid_state_transition() { + nss::ensure_initialized(); + let mut account = mock_account(); + let mut wrapper = RetryingAccount::new(&mut account); + let result = transition(&mut wrapper, FxaState::Disconnected, FxaEvent::Disconnect); + assert_fatal_invalid_transition(result); + } +}