From 95d5e0d2f9f71a5e4f39f4192d93f82eeef617cc Mon Sep 17 00:00:00 2001 From: Ben Dean-Kawamura Date: Tue, 12 Sep 2023 16:00:57 -0400 Subject: [PATCH] Reworked most of the Kotlin FxA wrapper Renamed the class from `PersistedFirefoxAccount` to `FxaClient` because class is now adding more functionality than just persistance. The main reason I chose `FxaClient` was that it doesn't conflict with any of the other class names. Added a state machine, similar to the android-components one but more lightweight. Applications send FxaActions the client sends them back FxaEvents. Everything happens in a single async task that reads actions from a channel and processing things serially. Well, that was the goal at least, there's a few places where we need break this model to make things work with the existing firefox-android code. See StateTypes.kt for details. Most getter functions are now async, using `withContext` plus a application-supplied `CoroutineContext`. Some getter functions are advertised as blocking because they don't make network requests. I don't think this is completely accurate (#5819), but I made this sync functions since that's what the application expects. Implemented a simple form of network retry / automatic auth checking. I'm not sure if this is the correct logic, but it should be simple to modify. Added functions to manually test this, the plan is to hook them up to the secret debug menu an Android. Updated sync_local_device_info to accept a None value for display name. This updates the device type/commands, then returns a LocalDevice record with the display name filled in from the server response. This is what we firefox-android needs for its startup. Switched to using FxaConfig and FxaServer directly. Don't persist the state on startup with a Config. This doesn't work on firefox-android since the state persistence callback is not setup on the layer above us. It also doesn't make much sense to save the state before any action has been taken -- you can always reproduce that state by starting with a new config. --- CHANGELOG.md | 8 + build.gradle | 1 + components/fxa-client/android/build.gradle | 4 + .../mozilla/appservices/fxaclient/Config.kt | 60 +- .../fxaclient/FxaActionProcessor.kt | 342 +++++ .../appservices/fxaclient/FxaClient.kt | 427 ++++++ .../fxaclient/PersistedFirefoxAccount.kt | 448 ------- .../appservices/fxaclient/StateTypes.kt | 252 ++++ .../resources/io/mockk/settings.properties | 1 + .../android/src/test/java/android/util/Log.kt | 25 + .../fxaclient/FxaActionProcessorTest.kt | 1177 +++++++++++++++++ components/fxa-client/src/auth.rs | 10 + components/fxa-client/src/fxa_client.udl | 6 + components/fxa-client/src/internal/mod.rs | 8 + .../fxa-client/src/internal/state_manager.rs | 13 + 15 files changed, 2283 insertions(+), 499 deletions(-) create mode 100644 components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt create mode 100644 components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt delete mode 100644 components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt create mode 100644 components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt create mode 100644 components/fxa-client/android/src/main/resources/io/mockk/settings.properties create mode 100644 components/fxa-client/android/src/test/java/android/util/Log.kt create mode 100644 components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b6aa8d91..754d67b289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ [Full Changelog](In progress) +## FxA Client + +### What's changed + +- Major changes to the Kotlin wrappers. They now present a higher-level action/event system. The + goal here is to allow new consumers to use this directly, without a lot of extra code like + android-components needed. + ## Rust log forwarder ### 🦊 What's Changed 🦊 diff --git a/build.gradle b/build.gradle index f6cdaf881c..fc0b63f048 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ buildscript { ktlint_version = '0.50.0' gradle_download_task_version = '5.2.1' junit_version = '4.13.2' + mockk_version = '1.13.8' mockito_core_version = '5.5.0' robolectric_core_version = '4.10.3' rust_android_gradle_version = '0.9.3' diff --git a/components/fxa-client/android/build.gradle b/components/fxa-client/android/build.gradle index b70607cd1c..681a77424e 100644 --- a/components/fxa-client/android/build.gradle +++ b/components/fxa-client/android/build.gradle @@ -4,6 +4,10 @@ apply from: "$rootDir/publish.gradle" dependencies { api project(':sync15') + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" + + testImplementation("io.mockk:mockk:${mockk_version}") + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutines_version" } ext.configureUniFFIBindgen("../src/fxa_client.udl") diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt index 761de5fd52..78c16dd0a7 100644 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt @@ -4,55 +4,13 @@ package mozilla.appservices.fxaclient -/** - * Config represents the server endpoint configurations needed for the - * authentication flow. - */ -class Config constructor( - val server: FxaServer, - val clientId: String, - val redirectUri: String, - val tokenServerUrlOverride: String? = null, -) { - enum class Server(val rustServer: FxaServer) { - RELEASE(FxaServer.Release), - STABLE(FxaServer.Stable), - STAGE(FxaServer.Stage), - CHINA(FxaServer.China), - LOCALDEV(FxaServer.LocalDev), - ; - - val contentUrl get() = this.rustServer.contentUrl - } - - constructor( - server: Server, - clientId: String, - redirectUri: String, - tokenServerUrlOverride: String? = null, - ) : this(server.rustServer, clientId, redirectUri, tokenServerUrlOverride) - - constructor( - contentUrl: String, - clientId: String, - redirectUri: String, - tokenServerUrlOverride: String? = null, - ) : this(FxaServer.Custom(contentUrl), clientId, redirectUri, tokenServerUrlOverride) - - val contentUrl get() = this.server.contentUrl - - // Rust defines a config and server class that's virtually identically to these. We should - // remove the wrapper soon, but let's wait until we have a batch of breaking changes and do them - // all at once. - fun intoRustConfig() = FxaConfig(server, clientId, redirectUri, tokenServerUrlOverride) +fun FxaServer.isCustom() = this is FxaServer.Custom + +fun FxaServer.contentUrl() = when (this) { + is FxaServer.Release -> "https://accounts.firefox.com" + is FxaServer.Stable -> "https://stable.dev.lcip.org" + is FxaServer.Stage -> "https://accounts.stage.mozaws.net" + is FxaServer.China -> "https://accounts.firefox.com.cn" + is FxaServer.LocalDev -> "http://127.0.0.1:3030" + is FxaServer.Custom -> this.url } - -val FxaServer.contentUrl: String - get() = when (this) { - is FxaServer.Release -> "https://accounts.firefox.com" - is FxaServer.Stable -> "https://stable.dev.lcip.org" - is FxaServer.Stage -> "https://accounts.stage.mozaws.net" - is FxaServer.China -> "https://accounts.firefox.com.cn" - is FxaServer.LocalDev -> "http://127.0.0.1:3030" - is FxaServer.Custom -> this.url - } diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt new file mode 100644 index 0000000000..0b6044fedc --- /dev/null +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt @@ -0,0 +1,342 @@ +/* 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/. */ + +package mozilla.appservices.fxaclient + +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Processes actions sent to [FxaClient.queueAction] and sends events to the [FxaEventHandler] + * + * See [FxaClient.queueAction] for details on why we handle actions like this. + * + * [FxaActionProcessor] is "inert" on construction and won't process any events. Call + * [runActionProcessorManager] start event processing. This creates a manager task that monitors + * the action handling loop and restarts it if it throws for any reason. + * + */ +internal class FxaActionProcessor( + private val inner: FirefoxAccount, + private val eventHandler: FxaEventHandler, + private val persistState: () -> Unit, + initialState: FxaAuthState = FxaAuthState.fromRust(inner.getAuthState()), +) { + // Set a high bound on the channel so that if our the processChannel() task dies, or is never + // started, we should eventually see error reports. + private val channel = Channel(100) + internal val retryLogic = RetryLogic() + internal var currentState = initialState + + @Volatile + internal var simulateNetworkErrorFlag: Boolean = false + + // Queue a new action for processing + fun queue(action: FxaAction) { + // trySend allows this function to be non-suspend and will never fail since the channel size + // is UNLIMITED + channel.trySend(action) + } + + // Shutdown the ActionProcessor + fun close() { + channel.close() + } + + internal suspend fun processChannel() { + Log.d(LOG_TAG, "processChannel started: $currentState") + for (action in channel) { + Log.d(LOG_TAG, "Processing action: $action") + processAction(action) + Log.d(LOG_TAG, "Action processed: $action") + } + // Exiting the for loop means the channel is closed, which means we are closed. + // Time to quit. + } + + @Suppress("ComplexMethod") + internal suspend fun processAction(action: FxaAction) { + val currentState = currentState + // Match on the current state since many actions are only valid from a particular state + when (currentState) { + is FxaAuthState.Connected -> when (action) { + is FxaAction.Disconnect -> handleDisconnect(action) + FxaAction.CheckAuthorization -> handleCheckAuthorization(currentState) + is FxaAction.InitializeDevice -> handleInitializeDevice(action) + is FxaAction.EnsureCapabilities -> handleEnsureCapabilities(action) + is FxaAction.SetDeviceName -> handleSetDeviceName(action) + is FxaAction.SetDevicePushSubscription -> handleSetDevicePushSubscription(action) + is FxaAction.SendSingleTab -> handleSendSingleTab(action) + else -> Log.e(LOG_TAG, "Invalid $action (state: $currentState)") + } + is FxaAuthState.Disconnected -> when (action) { + is FxaAction.BeginOAuthFlow -> handleBeginOAuthFlow(currentState, action) + is FxaAction.BeginPairingFlow -> handleBeginPairingFlow(currentState, action) + is FxaAction.CompleteOAuthFlow -> handleCompleteFlow(currentState, action) + is FxaAction.CancelOAuthFlow -> handleCancelFlow(currentState) + // If we see Disconnect or CheckAuthorization from the Disconnected state, just ignore it + FxaAction.CheckAuthorization -> Unit + is FxaAction.Disconnect -> Unit + else -> Log.e(LOG_TAG, "Invalid $action (state: $currentState)") + } + } + } + + internal suspend fun sendEvent(event: FxaEvent) { + Log.d(LOG_TAG, "Sending $event") + @Suppress("TooGenericExceptionCaught") + try { + eventHandler.onFxaEvent(event) + } catch (e: Exception) { + Log.e(LOG_TAG, "Exception sending $event", e) + } + } + + private suspend fun changeState(newState: FxaAuthState, transition: FxaAuthStateTransition) { + Log.d(LOG_TAG, "Changing state from $currentState to $newState") + currentState = newState + sendEvent(FxaEvent.AuthStateChanged(newState, transition)) + } + + // Perform an operation, retrying after network errors + private suspend fun withNetworkRetry(operation: suspend () -> T): T { + while (true) { + try { + if (simulateNetworkErrorFlag) { + simulateNetworkErrorFlag = false + throw FxaException.Network("Simulated Error") + } + return operation() + } catch (e: FxaException.Network) { + if (retryLogic.shouldRetryAfterNetworkError()) { + Log.d(LOG_TAG, "Network error: retrying") + continue + } else { + Log.d(LOG_TAG, "Network error: not retrying") + throw e + } + } + } + } + + // Perform an operation, retrying after network errors and calling checkAuthorizationStatus + // after auth errors + private suspend fun withRetry(operation: suspend () -> T): T { + while (true) { + try { + return withNetworkRetry(operation) + } catch (e: FxaException.Authentication) { + val currentState = currentState + + if (currentState !is FxaAuthState.Connected) { + throw e + } + + if (retryLogic.shouldRecheckAuthStatus()) { + Log.d(LOG_TAG, "Auth error: re-checking") + handleCheckAuthorization(currentState) + } else { + Log.d(LOG_TAG, "Auth error: disconnecting") + inner.disconnectFromAuthIssues() + persistState() + changeState(FxaAuthState.Disconnected(true), FxaAuthStateTransition.AUTH_CHECK_FAILED) + } + + if (this.currentState is FxaAuthState.Connected) { + continue + } else { + throw e + } + } + } + } + + private suspend fun handleBeginOAuthFlow(currentState: FxaAuthState.Disconnected, action: FxaAction.BeginOAuthFlow) { + handleBeginEitherOAuthFlow(currentState, action.result) { + inner.beginOauthFlow(action.scopes.toList(), action.entrypoint, MetricsParams(mapOf())) + } + } + + private suspend fun handleBeginPairingFlow(currentState: FxaAuthState.Disconnected, action: FxaAction.BeginPairingFlow) { + handleBeginEitherOAuthFlow(currentState, action.result) { + inner.beginPairingFlow(action.pairingUrl, action.scopes.toList(), action.entrypoint, MetricsParams(mapOf())) + } + } + + private suspend fun handleBeginEitherOAuthFlow(currentState: FxaAuthState.Disconnected, result: CompletableDeferred?, operation: () -> String) { + try { + val url = withRetry { operation() } + persistState() + changeState(currentState.copy(connecting = true), FxaAuthStateTransition.OAUTH_STARTED) + sendEvent(FxaEvent.BeginOAuthFlow(url)) + result?.complete(url) + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when handling BeginOAuthFlow", e) + persistState() + changeState(currentState.copy(connecting = false), FxaAuthStateTransition.OAUTH_FAILED_TO_BEGIN) + result?.complete(null) + } + } + + private suspend fun handleCompleteFlow(currentState: FxaAuthState.Disconnected, action: FxaAction.CompleteOAuthFlow) { + try { + withRetry { inner.completeOauthFlow(action.code, action.state) } + persistState() + changeState(FxaAuthState.Connected(), FxaAuthStateTransition.OAUTH_COMPLETE) + } catch (e: FxaException) { + persistState() + Log.e(LOG_TAG, "Exception when handling CompleteOAuthFlow", e) + changeState(currentState, FxaAuthStateTransition.OAUTH_FAILED_TO_COMPLETE) + } + } + + private suspend fun handleCancelFlow(currentState: FxaAuthState.Disconnected) { + // No need to call an inner method or persist the state, since the connecting flag is + // handled soley in this layer + changeState(currentState.copy(connecting = false), FxaAuthStateTransition.OAUTH_CANCELLED) + } + + private suspend fun handleInitializeDevice(action: FxaAction.InitializeDevice) { + handleDeviceOperation(action, FxaDeviceOperation.INITIALIZE_DEVICE, action.result) { + withRetry { inner.initializeDevice(action.name, action.deviceType, action.supportedCapabilities) } + } + } + + private suspend fun handleEnsureCapabilities(action: FxaAction.EnsureCapabilities) { + handleDeviceOperation(action, FxaDeviceOperation.ENSURE_CAPABILITIES, action.result) { + withRetry { inner.ensureCapabilities(action.supportedCapabilities) } + } + } + + private suspend fun handleSetDeviceName(action: FxaAction.SetDeviceName) { + handleDeviceOperation(action, FxaDeviceOperation.SET_DEVICE_NAME, action.result) { + withRetry { inner.setDeviceName(action.displayName) } + } + } + + private suspend fun handleSetDevicePushSubscription(action: FxaAction.SetDevicePushSubscription) { + handleDeviceOperation(action, FxaDeviceOperation.SET_DEVICE_PUSH_SUBSCRIPTION, action.result) { + withRetry { inner.setPushSubscription(DevicePushSubscription(action.endpoint, action.publicKey, action.authKey)) } + } + } + + private suspend fun handleSendSingleTab(action: FxaAction.SendSingleTab) { + try { + withRetry { inner.sendSingleTab(action.targetDeviceId, action.title, action.url) } + action.result?.complete(true) + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when handling $action", e) + action.result?.complete(false) + } + } + + private suspend fun handleDeviceOperation( + action: FxaAction, + operation: FxaDeviceOperation, + result: CompletableDeferred?, + block: suspend () -> LocalDevice, + ) { + try { + val localDevice = block() + sendEvent(FxaEvent.DeviceOperationComplete(operation, localDevice)) + result?.complete(true) + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when handling $action", e) + sendEvent(FxaEvent.DeviceOperationFailed(operation)) + result?.complete(false) + } + } + + private suspend fun handleDisconnect(action: FxaAction.Disconnect) { + if (action.fromAuthIssues) { + inner.disconnectFromAuthIssues() + persistState() + changeState(FxaAuthState.Disconnected(fromAuthIssues = true), FxaAuthStateTransition.AUTH_CHECK_FAILED) + } else { + inner.disconnect() + persistState() + changeState(FxaAuthState.Disconnected(), FxaAuthStateTransition.DISCONNECTED) + } + } + + private suspend fun handleCheckAuthorization(currentState: FxaAuthState.Connected) { + changeState(currentState.copy(authCheckInProgress = true), FxaAuthStateTransition.AUTH_CHECK_STARTED) + val success = try { + val status = withNetworkRetry { inner.checkAuthorizationStatus() } + status.active + } catch (e: FxaException.Authentication) { + // The Rust code should handle this exception, but if it doesn't, let's consider it a + // failed check. + Log.e(LOG_TAG, "Authentication exception when checking authorization status", e) + false + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when checking authorization status", e) + // It's not clear what's the Right Thing to do in this case, but considering it a success is + // better than a failure. We don't want users logged out because of network issues. + true + } + if (success) { + persistState() + changeState(currentState.copy(authCheckInProgress = false), FxaAuthStateTransition.AUTH_CHECK_SUCCESS) + } else { + inner.disconnectFromAuthIssues() + persistState() + changeState(FxaAuthState.Disconnected(true), FxaAuthStateTransition.AUTH_CHECK_FAILED) + } + } +} + +internal class RetryLogic { + private var lastNetworkRetry: Long = 0 + private var lastAuthCheck: Long = 0 + + fun shouldRetryAfterNetworkError(): Boolean { + val elasped = (System.currentTimeMillis() - lastNetworkRetry).milliseconds + if (elasped > 30.seconds) { + lastNetworkRetry = System.currentTimeMillis() + return true + } else { + return false + } + } + + fun shouldRecheckAuthStatus(): Boolean { + val elasped = (System.currentTimeMillis() - lastAuthCheck).milliseconds + if (elasped > 60.seconds) { + lastAuthCheck = System.currentTimeMillis() + return true + } else { + return false + } + } + + // For testing + fun fastForward(amount: Duration) { + lastNetworkRetry -= amount.inWholeMilliseconds + lastAuthCheck -= amount.inWholeMilliseconds + } +} + +// Startup a top-level job that keeps processChannel() running until `close()` is called. +internal fun runActionProcessorManager(actionProcessor: FxaActionProcessor, coroutineContext: CoroutineContext) { + CoroutineScope(coroutineContext).launch { + while (true) { + @Suppress("TooGenericExceptionCaught") + try { + actionProcessor.processChannel() + // If processChannel returns, then it's time to quit + break + } catch (e: Exception) { + Log.e(LOG_TAG, "Exception in processChannel", e) + } + } + } +} diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt new file mode 100644 index 0000000000..22123a5a49 --- /dev/null +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt @@ -0,0 +1,427 @@ +/* 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/. */ + +package mozilla.appservices.fxaclient + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +// This allows `adb logcat | grep fxa_client` to pick up all log items from this component +internal const val LOG_TAG = "fxa_client" + +/** + * Firefox account client + */ +class FxaClient private constructor( + private val inner: FirefoxAccount, + private val persistCallback: FxaPersistCallback, + // Note that this creates a reference cycle between FxaAccountManager, FxaClient, and FxaHandler + // that goes through the Rust code and therefore will never be broken. This is okay for now, + // since all of those objects are intended to live forever, but hopefully we can add some sort + // of weak reference support to side-step this. + private val eventHandler: FxaEventHandler, + coroutineContext: CoroutineContext, +) : AutoCloseable { + /** + * CoroutineContext context to run async tasks in. + * + * This is the coroutineContext passed in to the constructor plus a SupervisorJob. The + * SupervisorJob ensures that if one task fails the other still run. + * + * Because we use a single CoroutineContext, [close] can cancel all active jobs. + * + * This is public so applications can use the same context to run their jobs in. For example + * the android-components FxaManager uses it for its startup tasks. + */ + public val coroutineContext = coroutineContext + SupervisorJob() + + /** + * Create a new FxaClient + * + * @param config FxaConfig to initialize the client with + * @param FxaEventHandler Respond to FxA events + * @param coroutineContext CoroutineContext for the client. + */ + constructor(config: FxaConfig, persistCallback: FxaPersistCallback, eventHandler: FxaEventHandler, coroutineContext: CoroutineContext = Dispatchers.IO) : this( + FirefoxAccount(config), + persistCallback, + eventHandler, + coroutineContext, + ) + + companion object { + /** + * Restores a perisisted FxaClient + * + * @param json JSON data sent to FxaEventHandler.persistData + * @param FxaEventHandler Respond to FxA events + * @param coroutineContext CoroutineContext for the client. + * @return [FxaClient] representing the authentication state + */ + fun fromJson(json: String, persistCallback: FxaPersistCallback, eventHandler: FxaEventHandler, coroutineContext: CoroutineContext = Dispatchers.IO): FxaClient { + return FxaClient( + FirefoxAccount.fromJson(json), + persistCallback, + eventHandler, + coroutineContext, + ) + } + } + + private fun persistState() { + @Suppress("TooGenericExceptionCaught") + try { + persistCallback.persist(inner.toJson()) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error saving the FirefoxAccount state.") + } + } + + /** + * Queue an FxaAction for processing and immediately return + * + * Use the FxaEventHandler passed to the constructor to respond to the results of queued + * actions. + * + * Why do this? + * - Actions are processed serially. For example, there's no chance of CompleteOAuthFlow and + * Disconnect being executed at the same time from different threads. + * - Application events are also sent serially. If one action causes an [AUTH_CHECK_STARTED] + * state transition change, then the next causes [FxaAuthState.Disconnected], the + * [FxaEventHandler.onStateChange] callback for the second change won't be called until + * after the callback for the first change returns. This allows applications to ensure that + * their UI reflects the current state. + * - Actions are retried in the face of network errors and checkAuthorizationStatus is called on + * authorization errors. This allows the Rust client to recover when possible, for example + * from expired access tokens when it holds a valid refresh token. + */ + fun queueAction(action: FxaAction) { + actionProcessor.queue(action) + } + + // This handles queueAction for us + private val actionProcessor = FxaActionProcessor(inner, eventHandler, { persistState() }) + init { + runActionProcessorManager(actionProcessor, coroutineContext) + } + + /** + * Get the current authentication state + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun getAuthState(): FxaAuthState = FxaAuthState.fromRust(inner.getAuthState()) + + // Wraps method calls that don't change the state, like [getAccessToken] + private suspend fun wrapMethodCall(name: String, methodCall: () -> T): T { + Log.d(LOG_TAG, "Running: $name") + return withContext(coroutineContext) { + try { + methodCall() + } catch (e: FxaException.Authentication) { + queueAction(FxaAction.CheckAuthorization) + throw e + } + } + } + + // Does what wrapMethodCall does and also ensures that the state is perisisted at the end of the + // operation. + private suspend fun wrapMethodCallAndPersist(name: String, methodCall: () -> T): T { + return try { + wrapMethodCall(name, methodCall) + } finally { + // Use a finally block since we want to perisist the state regardless of if the method + // succeeded or not. + persistState() + } + } + + /** + * Fetches the profile object for the current client either from the existing cached account, + * or from the server (requires the client to have access to the profile scope). + * + * @param ignoreCache Fetch the profile information directly from the server + * @return [Profile] representing the user's basic profile info + * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getProfile(ignoreCache: Boolean): Profile = wrapMethodCallAndPersist("getProfile") { + inner.getProfile(ignoreCache) + } + + /** + * Convenience method to fetch the profile from a cached account by default, but fall back + * to retrieval from the server. + * + * @return [Profile] representing the user's basic profile info + * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getProfile(): Profile = getProfile(false) + + /** + * Fetches the token server endpoint, for authenticating to Firefox Sync via OAuth. + */ + suspend fun getTokenServerEndpointURL(): String = wrapMethodCall("getTokenServerEndpointURL") { + inner.getTokenServerEndpointUrl() + } + + /** + * Get the pairing URL to navigate to on the Auth side (typically a computer). + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun getPairingAuthorityURL(): String { + return inner.getPairingAuthorityUrl() + } + + /** + * Fetches the connection success url. + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun getConnectionSuccessURL(): String { + return inner.getConnectionSuccessUrl() + } + + /** + * Fetches the user's manage-account url. + * + * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getManageAccountURL(entrypoint: String): String = wrapMethodCall("getManageAccountURL") { + inner.getManageAccountUrl(entrypoint) + } + + /** + * Fetches the user's manage-devices url. + * + * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getManageDevicesURL(entrypoint: String): String = wrapMethodCall("getManageDevicesURL") { + inner.getManageDevicesUrl(entrypoint) + } + + /** + * Tries to fetch an access token for the given scope. + * + * @param scope Single OAuth scope (no spaces) for which the client wants access + * @param ttl time in seconds for which the token will be valid + * @return [AccessTokenInfo] that stores the token, along with its scopes and keys when complete + * @throws FxaException.Unauthorized We couldn't provide an access token + * for this scope. The caller should then start the OAuth Flow again with + * the desired scope. + */ + suspend fun getAccessToken( + scope: String, + ttl: Long? = null, + requireScopedKey: Boolean = false, + ): AccessTokenInfo = wrapMethodCallAndPersist("getAccessToken") { + inner.getAccessToken(scope, ttl, requireScopedKey) + } + + /** + * Tries to return a session token + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + * + * @throws FxaException Will send you an exception if there is no session token set + */ + fun getSessionToken(): String { + return inner.getSessionToken() + } + + /** + * Get the current device id + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + * + * @throws FxaException Will send you an exception if there is no device id set + */ + fun getCurrentDeviceId(): String { + return inner.getCurrentDeviceId() + } + + /** + * Provisions an OAuth code using the session token from state + * + * @param authParams Parameters needed for the authorization request + * This performs network requests, and should not be used on the main thread. + */ + suspend fun authorizeOAuthCode( + authParams: AuthorizationParameters, + ): String = wrapMethodCall("authorizeOAuthCode") { + inner.authorizeCodeUsingSessionToken(authParams) + } + + /** + * This method should be called when a request made with + * an OAuth token failed with an authentication error. + * It clears the internal cache of OAuth access tokens, + * so the caller can try to call [getAccessToken] or [getProfile] + * again. + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun clearAccessTokenCache() { + inner.clearAccessTokenCache() + } + + /** + * Saves the current account's authentication state as a JSON string, for persistence in + * the Android KeyStore/shared preferences. The authentication state can be restored using + * [FirefoxAccount.fromJSONString]. + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + * + * @return String containing the authentication details in JSON format + */ + fun toJsonString(): String { + return inner.toJson() + } + + /** + * Retrieves the list of the connected devices in the current account, including the current one. + */ + suspend fun getDevices(ignoreCache: Boolean = false): Array = wrapMethodCallAndPersist("getDevices") { + inner.getDevices(ignoreCache).toTypedArray() + } + + /** + * Retrieves any pending commands for the current device. + * This should be called semi-regularly as the main method of commands delivery (push) + * can sometimes be unreliable on mobile devices. + * If a persist callback is set and the host application failed to process the + * returned account events, they will never be seen again. + * + * @return A collection of [IncomingDeviceCommand] that should be handled by the caller. + */ + suspend fun pollDeviceCommands(): Array = wrapMethodCallAndPersist("pollDeviceCommands") { + inner.pollDeviceCommands().toTypedArray() + } + + /** + * Retrieves the account event associated with an + * incoming push message payload coming Firefox Accounts. + * Assumes the message that has been decrypted and authenticated by the Push crate. + * + * @return A collection of [AccountEvent] that should be handled by the caller. + */ + suspend fun handlePushMessage(payload: String): AccountEvent = wrapMethodCallAndPersist("handlePushMessage") { + inner.handlePushMessage(payload) + } + + /** + * Gather any telemetry which has been collected internally and return + * the result as a JSON string. + */ + suspend fun gatherTelemetry(): String = wrapMethodCall("gatherTelemetry") { + inner.gatherTelemetry() + } + + @Synchronized + override fun close() { + this.actionProcessor.close() + this.coroutineContext.cancel() + this.inner.destroy() + } + + /** + * Constructs a URL used to begin the OAuth flow for the requested scopes and keys. + * + * Deprecated: Call `queueAction(FxaAction.BeginOAuthFlow(...))` instead. + * + * This performs network requests, and should not be used on the main thread. + * + * @param scopes List of OAuth scopes for which the client wants access + * @param entrypoint to be used for metrics + * @param metricsParams optional parameters used for metrics + * @return String that resolves to the flow URL when complete + */ + @Deprecated("Send beginOAuthFlow to queueAction instead") + fun beginOAuthFlow( + scopes: Array, + entrypoint: String, + metricsParams: MetricsParams = MetricsParams(mapOf()), + ): String { + return this.inner.beginOauthFlow(scopes.toList(), entrypoint, metricsParams) + } + + /** + * Begins the pairing flow. + * + * Deprecated: Call `queueAction(FxaAction.BeginPairingFlow(...))` instead. + * + * This performs network requests, and should not be used on the main thread. + * + * @param pairingUrl the url to initilaize the paring flow with + * @param scopes List of OAuth scopes for which the client wants access + * @param entrypoint to be used for metrics + * @param metricsParams optional parameters used for metrics + * @return String that resoles to the flow URL when complete + */ + @Deprecated("Send beginPairingFlow to queueAction instead") + fun beginPairingFlow( + pairingUrl: String, + scopes: Array, + entrypoint: String, + metricsParams: MetricsParams = MetricsParams(mapOf()), + ): String { + return this.inner.beginPairingFlow(pairingUrl, scopes.toList(), entrypoint, metricsParams) + } + + /** + * Authenticates the current account using the code and state parameters fetched from the + * redirect URL reached after completing the sign in flow triggered by [beginOAuthFlow]. + * + * Deprecated: Call `queueAction(FxaAction.CompleteOAuthFlow(...))` instead. + * + * Modifies the FirefoxAccount state. + * + * This performs network requests, and should not be used on the main thread. + */ + @Deprecated("Send CompleteOAuthFlow to queueAction instead") + fun completeOAuthFlow(code: String, state: String) { + this.inner.completeOauthFlow(code, state) + this.persistState() + } + + // These are used to test error handling in real applications, for example in Firefox Android + // with the secret debug menu + public fun simulateNetworkError() { + Log.w(LOG_TAG, "simulateNetworkError") + actionProcessor.simulateNetworkErrorFlag = true + } + + public fun simulateTemporaryAuthTokenIssue() { + Log.w(LOG_TAG, "simulateTemporaryAuthTokenIssue") + inner.simulateTemporaryAuthTokenIssue() + } + + public fun simulatePermanentAuthTokenIssue() { + Log.w(LOG_TAG, "simulatePermanentAuthTokenIssue") + inner.simulatePermanentAuthTokenIssue() + } +} + +/** + * Implemented by applications to save the Fxa state + */ +interface FxaPersistCallback { + fun persist(data: String) +} + +/** + * Implemented by applications to respond to Fxa events + */ +interface FxaEventHandler { + suspend fun onFxaEvent(event: FxaEvent) +} diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt deleted file mode 100644 index 37b8ab8759..0000000000 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt +++ /dev/null @@ -1,448 +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/. */ - -package mozilla.appservices.fxaclient - -import android.util.Log -import mozilla.appservices.sync15.DeviceType - -/** - * PersistedFirefoxAccount represents the authentication state of a client. - * - * This is a thin wrapper around the `FirefoxAccount` object exposed from Rust. - * Its main job is to transparently manage persistence of the account state by - * calling the provided callback at appropriate times. - * - * In future it would be nice to push this logic down into the Rust code, - * once UniFFI's support for callback interfaces is a little more battle-tested. - * - */ -class PersistedFirefoxAccount(inner: FirefoxAccount, persistCallback: PersistCallback?) : AutoCloseable { - private var inner: FirefoxAccount = inner - private var persistCallback: PersistCallback? = persistCallback - - /** - * Create a PersistedFirefoxAccount using the given config. - * - * This does not make network requests, and can be used on the main thread. - * - */ - constructor(config: Config, persistCallback: PersistCallback? = null) : this( - FirefoxAccount(config.intoRustConfig()), - persistCallback, - ) { - // Persist the newly created instance state. - this.tryPersistState() - } - - companion object { - /** - * Restores the account's authentication state from a JSON string produced by - * [PersistedFirefoxAccount.toJSONString]. - * - * This does not make network requests, and can be used on the main thread. - * - * @return [PersistedFirefoxAccount] representing the authentication state - */ - fun fromJSONString(json: String, persistCallback: PersistCallback? = null): PersistedFirefoxAccount { - return PersistedFirefoxAccount(FirefoxAccount.fromJson(json), persistCallback) - } - } - - interface PersistCallback { - fun persist(data: String) - } - - /** - * Registers a PersistCallback that will be called every time the - * FirefoxAccount internal state has mutated. - * The FirefoxAccount instance can be later restored using the - * `fromJSONString` class method. - * It is the responsibility of the consumer to ensure the persisted data - * is saved in a secure location, as it can contain Sync Keys and - * OAuth tokens. - */ - fun registerPersistCallback(persistCallback: PersistCallback) { - this.persistCallback = persistCallback - } - - /** - * Unregisters any previously registered PersistCallback. - */ - fun unregisterPersistCallback() { - this.persistCallback = null - } - - private fun tryPersistState() { - this.persistCallback?.let { - val json: String - try { - json = this.toJSONString() - } catch (e: FxaException) { - Log.e("FirefoxAccount", "Error serializing the FirefoxAccount state.") - return - } - it.persist(json) - } - } - - /** - * Constructs a URL used to begin the OAuth flow for the requested scopes and keys. - * - * This performs network requests, and should not be used on the main thread. - * - * @param scopes List of OAuth scopes for which the client wants access - * @param entrypoint to be used for metrics - * @param metricsParams optional parameters used for metrics - * @return String that resolves to the flow URL when complete - */ - fun beginOAuthFlow( - scopes: Array, - entrypoint: String, - metricsParams: MetricsParams = MetricsParams(mapOf()), - ): String { - return this.inner.beginOauthFlow(scopes.toList(), entrypoint, metricsParams) - } - - /** - * Begins the pairing flow. - * - * This performs network requests, and should not be used on the main thread. - * - * @param pairingUrl the url to initilaize the paring flow with - * @param scopes List of OAuth scopes for which the client wants access - * @param entrypoint to be used for metrics - * @param metricsParams optional parameters used for metrics - * @return String that resoles to the flow URL when complete - */ - fun beginPairingFlow( - pairingUrl: String, - scopes: Array, - entrypoint: String, - metricsParams: MetricsParams = MetricsParams(mapOf()), - ): String { - return this.inner.beginPairingFlow(pairingUrl, scopes.toList(), entrypoint, metricsParams) - } - - /** - * Authenticates the current account using the code and state parameters fetched from the - * redirect URL reached after completing the sign in flow triggered by [beginOAuthFlow]. - * - * Modifies the FirefoxAccount state. - * - * This performs network requests, and should not be used on the main thread. - */ - fun completeOAuthFlow(code: String, state: String) { - this.inner.completeOauthFlow(code, state) - this.tryPersistState() - } - - /** - * Fetches the profile object for the current client either from the existing cached account, - * or from the server (requires the client to have access to the profile scope). - * - * This performs network requests, and should not be used on the main thread. - * - * @param ignoreCache Fetch the profile information directly from the server - * @return [Profile] representing the user's basic profile info - * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getProfile(ignoreCache: Boolean): Profile { - try { - return this.inner.getProfile(ignoreCache) - } finally { - this.tryPersistState() - } - } - - /** - * Convenience method to fetch the profile from a cached account by default, but fall back - * to retrieval from the server. - * - * This performs network requests, and should not be used on the main thread. - * - * @return [Profile] representing the user's basic profile info - * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getProfile(): Profile { - return getProfile(false) - } - - /** - * Fetches the token server endpoint, for authenticating to Firefox Sync via OAuth. - * - * This performs network requests, and should not be used on the main thread. - */ - fun getTokenServerEndpointURL(): String { - return this.inner.getTokenServerEndpointUrl() - } - - /** - * Get the pairing URL to navigate to on the Auth side (typically a computer). - * - * This does not make network requests, and can be used on the main thread. - */ - fun getPairingAuthorityURL(): String { - return this.inner.getPairingAuthorityUrl() - } - - /** - * Fetches the connection success url. - * - * This does not make network requests, and can be used on the main thread. - */ - fun getConnectionSuccessURL(): String { - return this.inner.getConnectionSuccessUrl() - } - - /** - * Fetches the user's manage-account url. - * - * This performs network requests, and should not be used on the main thread. - * - * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getManageAccountURL(entrypoint: String): String { - return this.inner.getManageAccountUrl(entrypoint) - } - - /** - * Fetches the user's manage-devices url. - * - * This performs network requests, and should not be used on the main thread. - * - * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getManageDevicesURL(entrypoint: String): String { - return this.inner.getManageDevicesUrl(entrypoint) - } - - /** - * Tries to fetch an access token for the given scope. - * - * This performs network requests, and should not be used on the main thread. - * It may modify the persisted account state. - * - * @param scope Single OAuth scope (no spaces) for which the client wants access - * @param ttl time in seconds for which the token will be valid - * @return [AccessTokenInfo] that stores the token, along with its scopes and keys when complete - * @throws FxaException.Unauthorized We couldn't provide an access token - * for this scope. The caller should then start the OAuth Flow again with - * the desired scope. - */ - fun getAccessToken(scope: String, ttl: Long? = null): AccessTokenInfo { - try { - return this.inner.getAccessToken(scope, ttl) - } finally { - this.tryPersistState() - } - } - - fun checkAuthorizationStatus(): AuthorizationInfo { - return this.inner.checkAuthorizationStatus() - } - - /** - * Tries to return a session token - * - * @throws FxaException Will send you an exception if there is no session token set - */ - fun getSessionToken(): String { - return this.inner.getSessionToken() - } - - /** - * Get the current device id - * - * @throws FxaException Will send you an exception if there is no device id set - */ - fun getCurrentDeviceId(): String { - return this.inner.getCurrentDeviceId() - } - - /** - * Provisions an OAuth code using the session token from state - * - * @param authParams Parameters needed for the authorization request - * This performs network requests, and should not be used on the main thread. - */ - fun authorizeOAuthCode( - authParams: AuthorizationParameters, - ): String { - return this.inner.authorizeCodeUsingSessionToken(authParams) - } - - /** - * This method should be called when a request made with - * an OAuth token failed with an authentication error. - * It clears the internal cache of OAuth access tokens, - * so the caller can try to call `getAccessToken` or `getProfile` - * again. - */ - fun clearAccessTokenCache() { - this.inner.clearAccessTokenCache() - } - - /** - * Saves the current account's authentication state as a JSON string, for persistence in - * the Android KeyStore/shared preferences. The authentication state can be restored using - * [FirefoxAccount.fromJSONString]. - * - * This does not make network requests, and can be used on the main thread. - * - * @return String containing the authentication details in JSON format - */ - fun toJSONString(): String { - return this.inner.toJson() - } - - /** - * Update the push subscription details for the current device. - * This needs to be called every time a push subscription is modified or expires. - * - * This performs network requests, and should not be used on the main thread. - * - * @param endpoint Push callback URL - * @param publicKey Public key used to encrypt push payloads - * @param authKey Auth key used to encrypt push payloads - */ - fun setDevicePushSubscription(endpoint: String, publicKey: String, authKey: String) { - try { - return this.inner.setPushSubscription(DevicePushSubscription(endpoint, publicKey, authKey)) - } finally { - this.tryPersistState() - } - } - - /** - * Update the display name (as shown in the FxA device manager, or the Send Tab target list) - * for the current device. - * - * This performs network requests, and should not be used on the main thread. - * - * @param displayName The current device display name - */ - fun setDeviceDisplayName(displayName: String) { - try { - return this.inner.setDeviceName(displayName) - } finally { - this.tryPersistState() - } - } - - /** - * Retrieves the list of the connected devices in the current account, including the current one. - * - * This performs network requests, and should not be used on the main thread. - */ - fun getDevices(ignoreCache: Boolean = false): Array { - return this.inner.getDevices(ignoreCache).toTypedArray() - } - - /** - * Disconnect from the account and optionaly destroy our device record. - * `beginOAuthFlow` will need to be called to reconnect. - * - * This performs network requests, and should not be used on the main thread. - */ - fun disconnect() { - this.inner.disconnect() - this.tryPersistState() - } - - /** - * Retrieves any pending commands for the current device. - * This should be called semi-regularly as the main method of commands delivery (push) - * can sometimes be unreliable on mobile devices. - * If a persist callback is set and the host application failed to process the - * returned account events, they will never be seen again. - * - * This performs network requests, and should not be used on the main thread. - * - * @return A collection of [IncomingDeviceCommand] that should be handled by the caller. - */ - fun pollDeviceCommands(): Array { - try { - return this.inner.pollDeviceCommands().toTypedArray() - } finally { - this.tryPersistState() - } - } - - /** - * Retrieves the account event associated with an - * incoming push message payload coming Firefox Accounts. - * Assumes the message that has been decrypted and authenticated by the Push crate. - * - * This performs network requests, and should not be used on the main thread. - * - * @return A collection of [AccountEvent] that should be handled by the caller. - */ - fun handlePushMessage(payload: String): AccountEvent { - try { - return this.inner.handlePushMessage(payload) - } finally { - this.tryPersistState() - } - } - - /** - * Ensure the current device is registered with the specified name and device type, with - * the required capabilities (at this time only Send Tab). - * This method should be called once per "device lifetime". - * - * This performs network requests, and should not be used on the main thread. - */ - fun initializeDevice(name: String, deviceType: DeviceType, supportedCapabilities: Set) { - this.inner.initializeDevice(name, deviceType, supportedCapabilities.toList()) - this.tryPersistState() - } - - /** - * Ensure that the supported capabilities described earlier in `initializeDevice` are A-OK. - * A set of capabilities to be supported by the Device must also be passed (at this time only - * Send Tab). - * - * As for now there's only the Send Tab capability, so we ensure the command is registered with the server. - * This method should be called at least every time the sync keys change (because Send Tab relies on them). - * - * This performs network requests, and should not be used on the main thread. - */ - fun ensureCapabilities(supportedCapabilities: Set) { - this.inner.ensureCapabilities(supportedCapabilities.toList()) - this.tryPersistState() - } - - /** - * Send a single tab to another device identified by its device ID. - * - * This performs network requests, and should not be used on the main thread. - * - * @param targetDeviceId The target Device ID - * @param title The document title of the tab being sent - * @param url The url of the tab being sent - */ - fun sendSingleTab(targetDeviceId: String, title: String, url: String) { - this.inner.sendSingleTab(targetDeviceId, title, url) - } - - /** - * Gather any telemetry which has been collected internally and return - * the result as a JSON string. - * - * This does not make network requests, and can be used on the main thread. - */ - fun gatherTelemetry(): String { - return this.inner.gatherTelemetry() - } - - @Synchronized - override fun close() { - this.inner.destroy() - } -} diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt new file mode 100644 index 0000000000..1ac9009274 --- /dev/null +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt @@ -0,0 +1,252 @@ +/* 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/. */ + +package mozilla.appservices.fxaclient + +import kotlinx.coroutines.CompletableDeferred +import mozilla.appservices.sync15.DeviceType + +/** + * FxA Action. + * + * The application sends these to [FxaClient.queueAction]. + * + * Note on [CompletableDeferred] result params: + * + * These exist for compatibility with the existing firefox-android codebase, but new code should use + * the `FxaEventHandler` interface to listen for events. + * + * The reason is Deferred result processing is not guaranteed to happen in-order. For example: + * - Thread A sends a SetDeviceName action that fails. + * - Slightly later, thread B sends a SetDeviceName action that succeeds. + * - It's possible that the code to handle thread B's result will execute before the code for thread A. + * - This means that the user may see a connection warning when they shouldn't. + * + * This isn't ideal, but it seems like issues will be rare in practice. The same issues could have + * happened with the previous system, where the android-components code called sync methods, like + * setDeviceName from async wrapper functions. + */ +sealed class FxaAction { + /** + * Begin an OAuth flow + * + * @param scopes OAuth scopes to request + * @param entrypoint OAuth entrypoint + * @param result If present, will be completed with the OAuth URL to navigate users too + */ + data class BeginOAuthFlow( + val scopes: Array, + val entrypoint: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Begin an OAuth flow using a paring code URL + * + * @param pairingUrl the url to initialize the paring flow with + * @param scopes OAuth scopes to request + * @param entrypoint OAuth entrypoint + * @param result If present, will be completed with the OAuth URL to navigate users too + */ + data class BeginPairingFlow( + val pairingUrl: String, + val scopes: Array, + val entrypoint: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Complete an OAuth flow, authenticating the current account. + * + * @param code query parameter from the redirect URL after completing the oauth flow + * @param state query parameter from the redirect URL after completing the oauth flow + */ + data class CompleteOAuthFlow( + val code: String, + val state: String, + ) : FxaAction() + + /** + * Cancel an OAuth flow + */ + object CancelOAuthFlow : FxaAction() + + /** + * Initialize device info on the server + * + * @param name Display name + * @param deviceType Device type + * @param supportedCapabilities Capabilities that the device supports + * @param result If present, will be completed with true for success and false for failure + */ + data class InitializeDevice( + val name: String, + val deviceType: DeviceType, + val supportedCapabilities: List, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Ensure capabilities are registered with the server + * + * @param supportedCapabilities Capabilities that the device supports + * @param result If present, will be completed with true for success and false for failure + */ + data class EnsureCapabilities( + val supportedCapabilities: List, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Update the display name (as shown in the FxA device manager, or the Send Tab target list) + * for the current device. + * + * @param displayName The current device display name + * @param result If present, will be completed with true for success and false for failure + */ + data class SetDeviceName( + val displayName: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Update the push subscription details for the current device. + * This needs to be called every time a push subscription is modified or expires. + * + * @param endpoint Push callback URL + * @param publicKey Public key used to encrypt push payloads + * @param authKey Auth key used to encrypt push payloads + * @param result If present, will be completed with true for success and false for failure + */ + data class SetDevicePushSubscription( + val endpoint: String, + val publicKey: String, + val authKey: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Send a single tab to another device identified by its device ID. + * + * @param targetDeviceId The target Device ID + * @param title The document title of the tab being sent + * @param url The url of the tab being sent + * @param result If present, will be completed with true for success and false for failure + */ + data class SendSingleTab( + val targetDeviceId: String, + val title: String, + val url: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Disconnect from the FxA server and destroy our device record. + * + * @param fromAuthIssues: are we disconnecting because of auth issues? Setting this flag + * changes `FxaEvent.AuthStateChanged` so that the `fromAuthIssues` flag is will set and the + * transition is `AUTH_CHECK_FAILED` + */ + data class Disconnect(val fromAuthIssues: Boolean = false) : FxaAction() + + /** + * Check the FxA authorization status. + */ + object CheckAuthorization : FxaAction() +} + +/** + * Fxa event + * + * These are the results of FxaActions and are sent by the Fxa client to the application via the + * FxaEventHandler interface. + */ +sealed class FxaEvent { + /** + * Called when the auth state changes. Applications should use this to update their UI. + */ + data class AuthStateChanged( + val newState: FxaAuthState, + val transition: FxaAuthStateTransition, + ) : FxaEvent() + + /** + * An action that updates the local device state completed successfully + */ + data class DeviceOperationComplete( + val operation: FxaDeviceOperation, + val localDevice: LocalDevice, + ) : FxaEvent() + + /** + * An action that updates the local device state failed + */ + data class DeviceOperationFailed( + val operation: FxaDeviceOperation, + ) : FxaEvent() + + /** + * Called to begin an oauth flow. The application must navigate the user to the URL to + * start the process. + */ + data class BeginOAuthFlow(val url: String) : FxaEvent() +} + +/** + * Kotlin authorization state class + * + * This is [FxaRustAuthState] with added data that Rust doesn't track yet. + */ +sealed class FxaAuthState { + /** + * Client has disconnected + * + * @property fromAuthIssues client was disconnected because of invalid auth tokens, for + * example because of a password reset on another device + * @property connecting is there an OAuth flow in progress? + */ + data class Disconnected( + val fromAuthIssues: Boolean = false, + val connecting: Boolean = false, + ) : FxaAuthState() + + /** + * Client is currently connected + * + * @property authCheckInProgress Client is checking the auth tokens and may disconnect soon + */ + data class Connected( + val authCheckInProgress: Boolean = false, + ) : FxaAuthState() + + companion object { + fun fromRust(authState: FxaRustAuthState): FxaAuthState { + return when (authState) { + is FxaRustAuthState.Connected -> FxaAuthState.Connected() + is FxaRustAuthState.Disconnected -> { + FxaAuthState.Disconnected(authState.fromAuthIssues) + } + } + } + } +} + +enum class FxaAuthStateTransition { + OAUTH_STARTED, + OAUTH_COMPLETE, + OAUTH_CANCELLED, + OAUTH_FAILED_TO_BEGIN, + OAUTH_FAILED_TO_COMPLETE, + DISCONNECTED, + AUTH_CHECK_STARTED, + AUTH_CHECK_FAILED, + AUTH_CHECK_SUCCESS, +} + +enum class FxaDeviceOperation { + INITIALIZE_DEVICE, + ENSURE_CAPABILITIES, + SET_DEVICE_NAME, + SET_DEVICE_PUSH_SUBSCRIPTION, +} diff --git a/components/fxa-client/android/src/main/resources/io/mockk/settings.properties b/components/fxa-client/android/src/main/resources/io/mockk/settings.properties new file mode 100644 index 0000000000..b8ba928e75 --- /dev/null +++ b/components/fxa-client/android/src/main/resources/io/mockk/settings.properties @@ -0,0 +1 @@ +stackTracesOnVerify=false diff --git a/components/fxa-client/android/src/test/java/android/util/Log.kt b/components/fxa-client/android/src/test/java/android/util/Log.kt new file mode 100644 index 0000000000..65ff25ea98 --- /dev/null +++ b/components/fxa-client/android/src/test/java/android/util/Log.kt @@ -0,0 +1,25 @@ +@file:JvmName("Log") + +// This file exists to make the unit tests happy + +package android.util + +fun d(tag: String, msg: String): Int { + println("DEBUG: $tag: $msg") + return 0 +} + +fun e(tag: String, msg: String): Int { + println("ERROR: $tag: $msg") + return 0 +} + +fun e(tag: String, msg: String, throwable: Throwable): Int { + println("ERROR: $tag: $msg $throwable") + return 0 +} + +fun w(tag: String, msg: String): Int { + println("WARN: $tag: $msg") + return 0 +} diff --git a/components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt b/components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt new file mode 100644 index 0000000000..ac5b502583 --- /dev/null +++ b/components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt @@ -0,0 +1,1177 @@ +/* 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/. */ + +package mozilla.appservices.fxaclient + +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import mozilla.appservices.sync15.DeviceType +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +val testLocalDevice = LocalDevice( + id = "device-id", + displayName = "My Phone", + deviceType = DeviceType.MOBILE, + capabilities = listOf(DeviceCapability.SEND_TAB), + pushSubscription = DevicePushSubscription("endpoint", "public-key", "auth-key"), + pushEndpointExpired = false, +) + +val testException = FxaException.Other("Test error") +val networkException = FxaException.Network("Test network error") +val authException = FxaException.Authentication("Test auth error") +val oauthStateException = FxaException.Other("Test oauth state error") + +val beginOAuthFlowAction = FxaAction.BeginOAuthFlow(arrayOf("scope1"), "test-entrypoint") +val beginPairingFlowAction = FxaAction.BeginPairingFlow("http:://example.com/pairing", arrayOf("scope1"), "test-entrypoint") +val completeAuthFlowAction = FxaAction.CompleteOAuthFlow(code = "test-code", state = "test-state") +val completeAuthFlowInvalidAction = FxaAction.CompleteOAuthFlow(code = "test-code", state = "bad-state") +val initializeDeviceAction = FxaAction.InitializeDevice("My Phone", DeviceType.MOBILE, listOf(DeviceCapability.SEND_TAB)) +val ensureCapabilitiesAction = FxaAction.EnsureCapabilities(listOf(DeviceCapability.SEND_TAB)) +val setDeviceNameAction = FxaAction.SetDeviceName("My Phone") +val setDevicePushSubscriptionAction = FxaAction.SetDevicePushSubscription("endpoint", "public-key", "auth-key") +val sendSingleTabAction = FxaAction.SendSingleTab("my-other-device", "My page", "http://example.com/sent-tab") + +internal data class Mocks( + val firefoxAccount: FirefoxAccount, + val eventHandler: FxaEventHandler, + val persistState: () -> Unit, + val actionProcessor: FxaActionProcessor, +) { + // Verify the effects of an action + // + // verifyBlock should verify all mock interactions, than return the expect new state of the + // actionProcessor. Pass in null (AKA NoEffects) to verify that there were no mock interactions + // and the state didn't change + suspend fun verifyAction(action: FxaAction, verifyBlock: ActionVerifier) { + if (verifyBlock == NeverHappens) { + return + } + val initialState = actionProcessor.currentState + actionProcessor.processAction(action) + when (verifyBlock) { + NoEffects -> assertEquals(initialState, actionProcessor.currentState) + else -> { + val expectedState = verifyBlock(firefoxAccount, eventHandler, persistState) + assertEquals(expectedState, actionProcessor.currentState) + } + } + confirmVerified(firefoxAccount, eventHandler, persistState) + clearMocks(firefoxAccount, eventHandler, persistState, answers = false, recordedCalls = true, verificationMarks = true) + } + + companion object { + fun create(initialState: FxaAuthState, throwing: Boolean = false): Mocks { + val firefoxAccount = mockk(relaxed = true).apply { + if (!throwing) { + every { getAuthState() } returns FxaRustAuthState.Disconnected(fromAuthIssues = false) + every { beginOauthFlow(any(), any(), any()) } returns "http://example.com/oauth-flow-start" + every { beginPairingFlow(any(), any(), any(), any()) } returns "http://example.com/pairing-flow-start" + every { initializeDevice(any(), any(), any()) } returns testLocalDevice + every { ensureCapabilities(any()) } returns testLocalDevice + every { setDeviceName(any()) } returns testLocalDevice + every { setPushSubscription(any()) } returns testLocalDevice + every { checkAuthorizationStatus() } returns AuthorizationInfo(active = true) + } else { + every { beginOauthFlow(any(), any(), any()) } throws testException + every { beginPairingFlow(any(), any(), any(), any()) } throws testException + every { completeOauthFlow(any(), any()) } throws testException + every { disconnect() } throws testException + every { initializeDevice(any(), any(), any()) } throws testException + every { ensureCapabilities(any()) } throws testException + every { setDeviceName(any()) } throws testException + every { setPushSubscription(any()) } throws testException + every { checkAuthorizationStatus() } throws testException + every { sendSingleTab(any(), any(), any()) } throws testException + } + } + val eventHandler = mockk(relaxed = true) + val persistState = mockk<() -> Unit>(relaxed = true, name = "tryPersistState") + val actionProcessor = FxaActionProcessor(firefoxAccount, eventHandler, persistState, initialState) + return Mocks(firefoxAccount, eventHandler, persistState, actionProcessor) + } + + // Check the effects processAction() calls for each possible state + // + // This checks each combination of: + // - connected / disconnected + // - Rust returns Ok() / Err() + // + // Note: this doesn't differentiate based on the FxaAuthState fields values, like + // `fromAuthIssues`. FxaActionProcessor sets these fields, but otherwise ignores them. + @Suppress("LongParameterList") + internal suspend fun verifyAction( + action: FxaAction, + whenDisconnected: ActionVerifier, + whenDisconnectedIfThrows: ActionVerifier, + whenConnected: ActionVerifier, + whenConnectedIfThrows: ActionVerifier, + initialStateDisconnected: FxaAuthState = FxaAuthState.Disconnected(), + initialStateConnected: FxaAuthState = FxaAuthState.Connected(), + ) { + create(initialStateDisconnected, false).verifyAction(action, whenDisconnected) + create(initialStateDisconnected, true).verifyAction(action, whenDisconnectedIfThrows) + create(initialStateConnected, false).verifyAction(action, whenConnected) + create(initialStateConnected, true).verifyAction(action, whenConnectedIfThrows) + } + } +} + +typealias ActionVerifier = suspend (FirefoxAccount, FxaEventHandler, () -> Unit) -> FxaAuthState + +// Used when an action should have no effects at all +val NoEffects: ActionVerifier = { _, _, _ -> FxaAuthState.Disconnected() } + +// Used when an action should never happen, for example FxaAction.Disconnect should never throw, so +// we use this rather than making a verifier to test an impossible pathe +val NeverHappens: ActionVerifier = { _, _, _ -> FxaAuthState.Disconnected() } + +/** + * This is the main unit test for the FxaActionProcessor. The goal here is to take every action and + * verify what happens it's processed when: + * * The client is connected / disconnected + * * The Rust client throws or doesn't throw an error + * + * "verify" means: + * * the correct calls were made to the Rust client + * * the correct events were emitted + * * the state was persisted (or not) + * * all of the above happened in the expected order + * * the new state of the FxaActionProcessor is correct + * + * This leads to 4 tests per action, which is a lot, but [Mocks.verifyAction] keeps things + * relatively simple. We could test more combinations, like what happens when different errors are + * thrown or test different field values for [FxaAuthState.Connected] and + * [FxaAuthState.Disconnected]. However, these distinctions usually don't matter, and we don't + * genally test all the combinations (although some individual tests do tests some of the + * combinations). + * + */ +class FxaActionProcessorTest { + @Test + fun `FxaActionProcessor handles BeginOAuthFlow`() = runTest { + Mocks.verifyAction( + beginOAuthFlowAction, + whenDisconnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginOauthFlow(listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_STARTED, + ), + ) + eventHandler.onFxaEvent( + FxaEvent.BeginOAuthFlow( + "http://example.com/oauth-flow-start", + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + }, + whenDisconnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginOauthFlow(listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_BEGIN, + ), + ) + } + FxaAuthState.Disconnected() + }, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles BeginPairingFlow`() = runTest { + Mocks.verifyAction( + beginPairingFlowAction, + whenDisconnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginPairingFlow("http:://example.com/pairing", listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_STARTED, + ), + ) + eventHandler.onFxaEvent( + FxaEvent.BeginOAuthFlow( + "http://example.com/pairing-flow-start", + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + }, + whenDisconnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginPairingFlow("http:://example.com/pairing", listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = false), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_BEGIN, + ), + ) + } + FxaAuthState.Disconnected(connecting = false) + }, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles CompleteOauthFlow`() = runTest { + Mocks.verifyAction( + completeAuthFlowAction, + initialStateDisconnected = FxaAuthState.Disconnected(connecting = true), + whenDisconnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "test-state") + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.OAUTH_COMPLETE, + ), + ) + } + FxaAuthState.Connected() + }, + whenDisconnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "test-state") + persistState() + // `connecting` should still be true in case another oauth flow is also in + // progress. In order to unset it, the application needs to send + // CancelOAuthFlow + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_COMPLETE, + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + }, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles a failed oauth complete, then a successful one`() = runTest { + val mocks = Mocks.create(FxaAuthState.Disconnected(connecting = true)) + every { mocks.firefoxAccount.completeOauthFlow(any(), any()) } throws oauthStateException + + mocks.verifyAction(completeAuthFlowInvalidAction) { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "bad-state") + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_COMPLETE, + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + } + + every { mocks.firefoxAccount.completeOauthFlow(any(), any()) } just runs + mocks.verifyAction(completeAuthFlowAction) { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "test-state") + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.OAUTH_COMPLETE, + ), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor handles CancelOauthFlow`() = runTest { + Mocks.verifyAction( + FxaAction.CancelOAuthFlow, + initialStateDisconnected = FxaAuthState.Disconnected(connecting = true), + whenDisconnected = { _, eventHandler, _ -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = false), + transition = FxaAuthStateTransition.OAUTH_CANCELLED, + ), + ) + } + FxaAuthState.Disconnected(connecting = false) + }, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles Disconnect`() = runTest { + Mocks.verifyAction( + FxaAction.Disconnect(), + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.disconnect() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(), + transition = FxaAuthStateTransition.DISCONNECTED, + ), + ) + } + FxaAuthState.Disconnected() + }, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles Disconnect(fromAuthIssues=true)`() = runTest { + Mocks.verifyAction( + FxaAction.Disconnect(fromAuthIssues = true), + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + }, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles CheckAuthorization`() = runTest { + Mocks.verifyAction( + FxaAction.CheckAuthorization, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + } + FxaAuthState.Connected() + }, + // If the auth check throws FxaException.Other, then then we currently consider that a + // success, rather than kicking the user out of the account. Other failure models are + // tested in the test cases below + whenConnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor disconnects if checkAuthorizationStatus returns active=false`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { mocks.firefoxAccount.checkAuthorizationStatus() } returns AuthorizationInfo(active = false) + mocks.verifyAction(FxaAction.CheckAuthorization) { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor disconnects if checkAuthorizationStatus throwns an auth exception`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { mocks.firefoxAccount.checkAuthorizationStatus() } throws authException + mocks.verifyAction(FxaAction.CheckAuthorization) { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor handles InitializeDevice`() = runTest { + Mocks.verifyAction( + initializeDeviceAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.initializeDevice("My Phone", DeviceType.MOBILE, listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.INITIALIZE_DEVICE, testLocalDevice)) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.initializeDevice("My Phone", DeviceType.MOBILE, listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.INITIALIZE_DEVICE)) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles EnsureCapabilities`() = runTest { + Mocks.verifyAction( + ensureCapabilitiesAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.ensureCapabilities(listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.ENSURE_CAPABILITIES, testLocalDevice), + ) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.ensureCapabilities(listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationFailed(FxaDeviceOperation.ENSURE_CAPABILITIES), + ) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles SetDeviceName`() = runTest { + Mocks.verifyAction( + setDeviceNameAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME), + ) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles SetDevicePushSubscription`() = runTest { + Mocks.verifyAction( + setDevicePushSubscriptionAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.setPushSubscription( + DevicePushSubscription("endpoint", "public-key", "auth-key"), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_PUSH_SUBSCRIPTION, testLocalDevice)) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.setPushSubscription( + DevicePushSubscription("endpoint", "public-key", "auth-key"), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_PUSH_SUBSCRIPTION)) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles SendSingleTab`() = runTest { + Mocks.verifyAction( + sendSingleTabAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, _, _ -> + coVerifySequence { + inner.sendSingleTab("my-other-device", "My page", "http://example.com/sent-tab") + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, _, _ -> + coVerifySequence { + inner.sendSingleTab("my-other-device", "My page", "http://example.com/sent-tab") + // Should we notify clients if this fails? There doesn't seem like there's much + // they can do about it. + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor sends OAuth results to the deferred`() = runTest { + val mocks = Mocks.create(FxaAuthState.Disconnected()) + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginOAuthFlowAction.copy(result = it)) + assertEquals(it.await(), "http://example.com/oauth-flow-start") + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginPairingFlowAction.copy(result = it)) + assertEquals(it.await(), "http://example.com/pairing-flow-start") + } + + every { mocks.firefoxAccount.beginOauthFlow(any(), any(), any()) } throws testException + every { mocks.firefoxAccount.beginPairingFlow(any(), any(), any(), any()) } throws testException + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginOAuthFlowAction.copy(result = it)) + assertEquals(it.await(), null) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginPairingFlowAction.copy(result = it)) + assertEquals(it.await(), null) + } + } + + @Test + fun `FxaActionProcessor sends Device operation results to the deferred`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + + CompletableDeferred().let { + mocks.actionProcessor.processAction(initializeDeviceAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(ensureCapabilitiesAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDeviceNameAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDevicePushSubscriptionAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(sendSingleTabAction.copy(result = it)) + assertEquals(it.await(), true) + } + + mocks.firefoxAccount.apply { + every { initializeDevice(any(), any(), any()) } throws testException + every { ensureCapabilities(any()) } throws testException + every { setDeviceName(any()) } throws testException + every { setPushSubscription(any()) } throws testException + every { sendSingleTab(any(), any(), any()) } throws testException + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(initializeDeviceAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(ensureCapabilitiesAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDeviceNameAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDevicePushSubscriptionAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(sendSingleTabAction.copy(result = it)) + assertEquals(it.await(), false) + } + } + + @Test + fun `FxaActionProcessor catches errors when sending events`() = runTest { + val eventHandler = mockk().apply { + coEvery { onFxaEvent(any()) } answers { + throw testException + } + } + FxaActionProcessor(mockk(), eventHandler, mockk(), initialState = FxaAuthState.Connected()).sendEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + // Check that the handler was called and threw an exception, but sendEvent caught it + coVerify { + eventHandler.onFxaEvent(any()) + } + } + + @Test + fun `FxaActionProcessor has a manager job that restarts when processChannel throws`() = runTest { + var firstTime = true + val actionProcessor = mockk(relaxed = true) + coEvery { actionProcessor.processChannel() } answers { + if (firstTime) { + // First time around we throw + firstTime = false + @Suppress("TooGenericExceptionThrown") + throw Exception("Test errror") + } else { + // Second time around we quit gracefully + } + } + // Run the manager job, it should restart `processChannel` when it throws the first time, + // and return when it successfully returns + val testDispatcher = StandardTestDispatcher() + runActionProcessorManager(actionProcessor, testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 2) { actionProcessor.processChannel() } + } +} + +class FxaRetryTest { + @Test + fun `FxaActionProcessor retries after network errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This throws FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This time it work + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor fails after 2 network errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { mocks.firefoxAccount.setDeviceName(any()) } throws networkException + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This throws FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This throws again, so the operation fails + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor fails after multiple network errors in a short time`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This fails with FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This time it works + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(29.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This throws again and the timeout period is still active, we should fail + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor retrys network errors again after a timeout period`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This fails with FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This time it works + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(31.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // Timeout period over, we should retry this time + inner.setDeviceName("My Phone") + // This time it works + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor calls checkAuthorizationStatus after auth errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerifySequence { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor fails after 2 auth errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerifySequence { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. but this throws again, + inner.setDeviceName("My Phone") + // ..so save the state, transition to disconnected, and make the operation fail + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor fails if the auth check fails`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + every { mocks.firefoxAccount.checkAuthorizationStatus() } returns AuthorizationInfo(active = false) + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check fails + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + // .. so the operation fails + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor fails after multiple auth errors in a short time`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(59.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws, + inner.setDeviceName("My Phone") + // ..so save the state, transition to disconnected, and make the operation fail + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor checks authorization again after timeout period passes`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(61.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // Timeout period over, we should recheck the auth status this time + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor retries after an auth + network exception`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + every { + mocks.firefoxAccount.checkAuthorizationStatus() + } throws networkException andThen AuthorizationInfo(active = true) + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + // This throws a network error, we should retry + inner.checkAuthorizationStatus() + // This time it works + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor retries after a network + auth exception`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + every { + mocks.firefoxAccount.checkAuthorizationStatus() + } throws networkException andThen AuthorizationInfo(active = true) + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Network should try again + inner.setDeviceName("My Phone") + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + // This works + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } +} diff --git a/components/fxa-client/src/auth.rs b/components/fxa-client/src/auth.rs index 75141ba3fc..6c68461cc6 100644 --- a/components/fxa-client/src/auth.rs +++ b/components/fxa-client/src/auth.rs @@ -173,6 +173,16 @@ impl FirefoxAccount { pub fn disconnect_from_auth_issues(&self) { self.internal.lock().disconnect(true) } + + /// Used by the application to test auth token issues + pub fn simulate_temporary_auth_token_issue(&self) { + self.internal.lock().simulate_temporary_auth_token_issue() + } + + /// Used by the application to test auth token issues + pub fn simulate_permanent_auth_token_issue(&self) { + self.internal.lock().simulate_permanent_auth_token_issue() + } } /// Information about the authorization state of the application. diff --git a/components/fxa-client/src/fxa_client.udl b/components/fxa-client/src/fxa_client.udl index 66c7c1d0eb..ddc49a8bf1 100644 --- a/components/fxa-client/src/fxa_client.udl +++ b/components/fxa-client/src/fxa_client.udl @@ -670,6 +670,12 @@ interface FirefoxAccount { // [Throws=FxaError] string gather_telemetry(); + + // Used by the application to test auth token issues + void simulate_temporary_auth_token_issue(); + + // Used by the application to test auth token issues + void simulate_permanent_auth_token_issue(); }; dictionary FxaConfig { diff --git a/components/fxa-client/src/internal/mod.rs b/components/fxa-client/src/internal/mod.rs index 62e5fbf3a2..3ba97b55d7 100644 --- a/components/fxa-client/src/internal/mod.rs +++ b/components/fxa-client/src/internal/mod.rs @@ -225,6 +225,14 @@ impl FirefoxAccount { self.clear_devices_and_attached_clients_cache(); self.telemetry = FxaTelemetry::new(); } + + pub fn simulate_temporary_auth_token_issue(&mut self) { + self.state.simulate_temporary_auth_token_issue() + } + + pub fn simulate_permanent_auth_token_issue(&mut self) { + self.state.simulate_permanent_auth_token_issue() + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/components/fxa-client/src/internal/state_manager.rs b/components/fxa-client/src/internal/state_manager.rs index ef8204812f..d128c16734 100644 --- a/components/fxa-client/src/internal/state_manager.rs +++ b/components/fxa-client/src/internal/state_manager.rs @@ -191,6 +191,19 @@ impl StateManager { self.persisted_state.access_token_cache.clear(); self.persisted_state.server_local_device_info = None; } + + pub fn simulate_temporary_auth_token_issue(&mut self) { + for (_, access_token) in self.persisted_state.access_token_cache.iter_mut() { + access_token.token = "invalid-data".to_owned() + } + } + + /// Used by the application to test auth token issues + pub fn simulate_permanent_auth_token_issue(&mut self) { + self.persisted_state.session_token = None; + self.persisted_state.refresh_token = None; + self.persisted_state.access_token_cache.clear(); + } } #[cfg(test)]