diff options
Diffstat (limited to 'mobile/android/android-components/components/service/firefox-accounts')
13 files changed, 358 insertions, 161 deletions
diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt index 37af0f5b76..e5f8a665eb 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt @@ -11,7 +11,6 @@ import mozilla.appservices.fxaclient.FxaRustAuthState import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.concept.sync.AccountEvent import mozilla.components.concept.sync.AccountEventsObserver -import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.StatePersistenceCallback import mozilla.components.lib.dataprotect.SecureAbove22Preferences import mozilla.components.service.fxa.manager.FxaAccountManager @@ -26,16 +25,16 @@ const val FXA_STATE_KEY = "fxaState" * Represents state of our account on disk - is it new, or restored? */ internal sealed class AccountOnDisk : WithAccount { - data class Restored(val account: OAuthAccount) : AccountOnDisk() { + data class Restored(val account: FirefoxAccount) : AccountOnDisk() { override fun account() = account } - data class New(val account: OAuthAccount) : AccountOnDisk() { + data class New(val account: FirefoxAccount) : AccountOnDisk() { override fun account() = account } } internal interface WithAccount { - fun account(): OAuthAccount + fun account(): FirefoxAccount } /** @@ -80,16 +79,16 @@ open class StorageWrapper( } } - private fun watchAccount(account: OAuthAccount) { + private fun watchAccount(account: FirefoxAccount) { account.registerPersistenceCallback(statePersistenceCallback) account.deviceConstellation().register(accountEventsIntegration) } /** - * Exists strictly for testing purposes, allowing tests to specify their own implementation of [OAuthAccount]. + * Exists strictly for testing purposes, allowing tests to specify their own implementation of [FirefoxAccount]. */ @VisibleForTesting - open fun obtainAccount(): OAuthAccount = FirefoxAccount(serverConfig, crashReporter) + open fun obtainAccount(): FirefoxAccount = FirefoxAccount(serverConfig, crashReporter) } /** @@ -110,7 +109,7 @@ internal class AccountEventsIntegration( internal interface AccountStorage { @Throws(Exception::class) - fun read(): OAuthAccount? + fun read(): FirefoxAccount? fun write(accountState: String) fun clear() } @@ -156,7 +155,7 @@ internal class SharedPrefAccountStorage( * @throws FxaException if JSON failed to parse into a [FirefoxAccount]. */ @Throws(FxaException::class) - override fun read(): OAuthAccount? { + override fun read(): FirefoxAccount? { val savedJSON = accountPreferences().getString(FXA_STATE_KEY, null) ?: return null @@ -244,7 +243,7 @@ internal class SecureAbove22AccountStorage( * @throws FxaException if JSON failed to parse into a [FirefoxAccount]. */ @Throws(FxaException::class) - override fun read(): OAuthAccount? { + override fun read(): FirefoxAccount? { return store.getString(KEY_ACCOUNT_STATE).also { // If account state is missing, but we expected it to be present, report an exception. if (it == null && prefs.getBoolean(PREF_KEY_HAS_STATE, false)) { diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt index 7fc31785e3..14e3e7d105 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt @@ -17,6 +17,7 @@ import mozilla.components.concept.sync.DeviceConstellation import mozilla.components.concept.sync.FxAEntryPoint import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.StatePersistenceCallback +import mozilla.components.concept.sync.UserData import mozilla.components.support.base.log.logger.Logger typealias PersistCallback = mozilla.appservices.fxaclient.FxaClient.PersistCallback @@ -96,6 +97,10 @@ class FirefoxAccount internal constructor( internal fun getAuthState() = inner.getAuthState() + internal fun simulateNetworkError() = inner.simulateNetworkError() + internal fun simulateTemporaryAuthTokenIssue() = inner.simulateTemporaryAuthTokenIssue() + internal fun simulatePermanentAuthTokenIssue() = inner.simulatePermanentAuthTokenIssue() + override suspend fun beginOAuthFlow( scopes: Set<String>, entryPoint: FxAEntryPoint, @@ -128,6 +133,12 @@ class FirefoxAccount internal constructor( } } + override suspend fun setUserData(userData: UserData) { + handleFxaExceptions(logger, "setUserData", { null }) { + inner.setUserData(userData.into()) + } + } + override fun getCurrentDeviceId(): String? { // This is awkward, yes. Underlying method simply reads some data from in-memory state, and yet it throws // in case that data isn't there. See https://github.com/mozilla/application-services/issues/2202. diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt index 99c4fb196f..88e4eebd4c 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt @@ -191,6 +191,9 @@ class FxaDeviceConstellation( crashReporter?.submitCaughtException(error) } } + is DeviceCommandOutgoing.CloseTab -> { + account.closeTabs(targetDeviceId, outgoingCommand.urls) + } else -> logger.debug("Skipped sending unsupported command type: $outgoingCommand") } null diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt index 737ff3c273..d489686cdd 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt @@ -18,6 +18,7 @@ import mozilla.components.concept.sync.DeviceCapability import mozilla.components.concept.sync.DeviceType import mozilla.components.concept.sync.OAuthScopedKey import mozilla.components.concept.sync.SyncAuthInfo +import mozilla.components.concept.sync.UserData import mozilla.appservices.fxaclient.DeviceCapability as RustDeviceCapability import mozilla.appservices.fxaclient.DevicePushSubscription as RustDevicePushSubscription import mozilla.appservices.sync15.DeviceType as RustDeviceType @@ -106,6 +107,20 @@ fun Profile.into(): mozilla.components.concept.sync.Profile { ) } +/** + * Converts the android-components defined [UserData] type into + * the application-services one, so consumers of android-components + * do not have to know about application services. + */ +fun UserData.into(): mozilla.appservices.fxaclient.UserData { + return mozilla.appservices.fxaclient.UserData( + sessionToken, + uid, + email, + verified, + ) +} + internal fun RustDeviceType.into(): DeviceType { return when (this) { RustDeviceType.DESKTOP -> DeviceType.DESKTOP @@ -139,6 +154,7 @@ fun DeviceType.into(): RustDeviceType { fun DeviceCapability.into(): RustDeviceCapability { return when (this) { DeviceCapability.SEND_TAB -> RustDeviceCapability.SEND_TAB + DeviceCapability.CLOSE_TABS -> RustDeviceCapability.CLOSE_TABS } } @@ -149,6 +165,7 @@ fun DeviceCapability.into(): RustDeviceCapability { fun RustDeviceCapability.into(): DeviceCapability { return when (this) { RustDeviceCapability.SEND_TAB -> DeviceCapability.SEND_TAB + RustDeviceCapability.CLOSE_TABS -> DeviceCapability.CLOSE_TABS } } @@ -240,6 +257,7 @@ fun AccountEvent.into(): mozilla.components.concept.sync.AccountEvent { fun IncomingDeviceCommand.into(): mozilla.components.concept.sync.DeviceCommandIncoming { return when (this) { is IncomingDeviceCommand.TabReceived -> this.into() + is IncomingDeviceCommand.TabsClosed -> this.into() } } @@ -249,3 +267,10 @@ fun IncomingDeviceCommand.TabReceived.into(): mozilla.components.concept.sync.De entries = this.payload.entries.map { it.into() }, ) } + +fun IncomingDeviceCommand.TabsClosed.into(): mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed { + return mozilla.components.concept.sync.DeviceCommandIncoming.TabsClosed( + from = this.sender?.into(), + urls = this.payload.urls, + ) +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt index 03a7d25635..71232c0815 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt @@ -8,9 +8,11 @@ import android.content.Context import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import mozilla.appservices.fxaclient.FxaStateCheckerEvent import mozilla.appservices.fxaclient.FxaStateCheckerState @@ -26,6 +28,7 @@ import mozilla.components.concept.sync.FxAEntryPoint import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile import mozilla.components.concept.sync.ServiceResult +import mozilla.components.concept.sync.UserData import mozilla.components.service.fxa.AccessTokenUnexpectedlyWithoutKey import mozilla.components.service.fxa.AccountManagerException import mozilla.components.service.fxa.AccountOnDisk @@ -344,11 +347,14 @@ open class FxaAccountManager( * @param pairingUrl Optional pairing URL in case a pairing flow is being initiated. * @param entrypoint an enum representing the feature entrypoint requesting the URL. * the entrypoint is used in telemetry. + * @param authScopes The oAuth scopes being requested, if none are provided + * we default to the scopes provided when constructing [FxaAccountManager] * @return An authentication url which is to be presented to the user. */ suspend fun beginAuthentication( pairingUrl: String? = null, entrypoint: FxAEntryPoint, + authScopes: Set<String> = scopes, ): String? = withContext(coroutineContext) { // It's possible that at this point authentication is considered to be "in-progress". // For example, if user started authentication flow, but cancelled it (closing a custom tab) @@ -357,9 +363,9 @@ open class FxaAccountManager( processQueue(Event.Progress.CancelAuth) val event = if (pairingUrl != null) { - Event.Account.BeginPairingFlow(pairingUrl, entrypoint) + Event.Account.BeginPairingFlow(pairingUrl, entrypoint, authScopes) } else { - Event.Account.BeginEmailFlow(entrypoint) + Event.Account.BeginEmailFlow(entrypoint, authScopes) } // Process the event, then use the new state to check the result of the operation @@ -375,6 +381,16 @@ open class FxaAccountManager( } /** + * Sets the user's data received from the web content. + * **NOTE**: This is only useful for applications that are user agents, that + * require the user's session token, and thus isn't a part of the state machine + * @param userData: The user's data as given by the web channel, including the session token + */ + suspend fun setUserData(userData: UserData) = withContext(coroutineContext) { + account.setUserData(userData) + } + + /** * Finalize authentication that was started via [beginAuthentication]. * * If authentication wasn't started via this manager we won't accept this authentication attempt, @@ -590,10 +606,10 @@ open class FxaAccountManager( } else { null } - val entrypoint = if (via is Event.Account.BeginEmailFlow) { - via.entrypoint + val (entrypoint, authScopes) = if (via is Event.Account.BeginEmailFlow) { + Pair(via.entrypoint, via.scopes) } else if (via is Event.Account.BeginPairingFlow) { - via.entrypoint + Pair(via.entrypoint, via.scopes) } else { // This should be impossible, both `BeginPairingFlow` and `BeginEmailFlow` // have a required `entrypoint` and we are matching against only instances @@ -601,7 +617,7 @@ open class FxaAccountManager( throw IllegalStateException("BeginningAuthentication with a flow that is neither email nor pairing") } val result = withRetries(logger, MAX_NETWORK_RETRIES) { - pairingUrl.asAuthFlowUrl(account, scopes, entrypoint = entrypoint) + pairingUrl.asAuthFlowUrl(account, authScopes, entrypoint = entrypoint) } when (result) { is Result.Success -> { @@ -935,4 +951,60 @@ open class FxaAccountManager( accountManager.syncStatusObserverRegistry.notifyObservers { onError(error) } } } + + /** + * Hook this up to the secret debug menu to simulate a network error + * + * Typical usage is: + * - `adb logcat | grep fxa_client` + * - Trigger this via the secret debug menu item. + * - Watch the logs. You should see the client perform a call to `get_profile', see a + * network error, then recover. + * - Note: the logs will be more clear once we switch the code to using the app-services state + * machine. + * - Check the UI, it should be in an authenticated state. + */ + public fun simulateNetworkError() { + account.simulateNetworkError() + CoroutineScope(coroutineContext).launch { + refreshProfile(true) + } + } + + /** + * Hook this up to the secret debug menu to simulate a temporary auth error + * + * Typical usage is: + * - `adb logcat | grep fxa_client` + * - Trigger this via the secret debug menu item. + * - Watch the logs. You should see the client perform a call to `get_profile', see an + * auth error, then recover. + * - Check the UI, it should be in an authenticated state. + */ + public fun simulateTemporaryAuthTokenIssue() { + account.simulateTemporaryAuthTokenIssue() + SyncAuthInfoCache(context).clear() + CoroutineScope(coroutineContext).launch { + refreshProfile(true) + } + } + + /** + * Hook this up to the secret debug menu to simulate an unrecoverable auth error + * + * Typical usage is: + * - `adb logcat | grep fxa_client` + * - Trigger this via the secret debug menu item. + * - Initiaite a sync, or perform some other action that requires authentication. + * - Watch the logs. You should see the client perform a call to `get_profile', see an + * auth error, then fail to recover. + * - Check the UI, it should be in an authentication problems state. + */ + public fun simulatePermanentAuthTokenIssue() { + account.simulatePermanentAuthTokenIssue() + SyncAuthInfoCache(context).clear() + CoroutineScope(coroutineContext).launch { + refreshProfile(true) + } + } } diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt index 111715703a..50858c2bbe 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt @@ -52,10 +52,31 @@ import mozilla.components.service.fxa.FxaAuthData * State transitions are described by a transition matrix, which is described in [State.next]. */ -internal sealed class AccountState { +/** + * Represents a [State.Idle] in the accounts state machine detailing the state of the account + * lifecycle. + */ +sealed class AccountState { + /** + * Account is logged in and authenticated. + */ object Authenticated : AccountState() + + /** + * Account is authenticating. + * + * @property oAuthUrl OAuth URL to be loaded to go through the authentication flow. + */ data class Authenticating(val oAuthUrl: String) : AccountState() + + /** + * Account needs to be re-authenticated (e.g. due to a password change). + */ object AuthenticationProblem : AccountState() + + /** + * No authenticated account is available (e.g. account is logged out). + */ object NotAuthenticated : AccountState() } @@ -70,8 +91,12 @@ internal enum class ProgressState { internal sealed class Event { internal sealed class Account : Event() { internal object Start : Account() - data class BeginEmailFlow(val entrypoint: FxAEntryPoint) : Account() - data class BeginPairingFlow(val pairingUrl: String?, val entrypoint: FxAEntryPoint) : Account() + data class BeginEmailFlow(val entrypoint: FxAEntryPoint, val scopes: Set<String>) : Account() + data class BeginPairingFlow( + val pairingUrl: String?, + val entrypoint: FxAEntryPoint, + val scopes: Set<String>, + ) : Account() data class AuthenticationError(val operation: String, val errorCountWithinTheTimeWindow: Int = 1) : Account() { override fun toString(): String { return "${this.javaClass.simpleName} - $operation" diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt index b09e7f3dcc..eb2d7960cf 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt @@ -6,6 +6,7 @@ package mozilla.components.service.fxa.store import mozilla.components.concept.sync.ConstellationState import mozilla.components.lib.state.Action +import mozilla.components.service.fxa.manager.AccountState /** * Actions for updating the global [SyncState] via [SyncStore]. @@ -22,6 +23,11 @@ sealed class SyncAction : Action { data class UpdateAccount(val account: Account?) : SyncAction() /** + * Update the [SyncState.accountState] of the [SyncStore]. + */ + data class UpdateAccountState(val accountState: AccountState) : SyncAction() + + /** * Update the [SyncState.constellationState] of the [SyncStore]. */ data class UpdateDeviceConstellation(val deviceConstellation: ConstellationState) : SyncAction() diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt index 86c9d05d2a..91e3870251 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt @@ -9,18 +9,21 @@ import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile import mozilla.components.lib.state.State +import mozilla.components.service.fxa.manager.AccountState import mozilla.components.service.fxa.sync.WorkManagerSyncManager /** * Global state of Sync. * * @property status The current status of Sync. - * @property account The current Sync account, if any. + * @property account The current Sync [Account], if any. + * @property accountState The current [AccountState] of Sync. * @property constellationState The current constellation state, if any. */ data class SyncState( val status: SyncStatus = SyncStatus.NotInitialized, val account: Account? = null, + val accountState: AccountState = AccountState.NotAuthenticated, val constellationState: ConstellationState? = null, ) : State diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt index 50f4b6747e..487d9bd1ea 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt @@ -22,6 +22,7 @@ private fun reduce(syncState: SyncState, syncAction: SyncAction): SyncState { return when (syncAction) { is SyncAction.UpdateSyncStatus -> syncState.copy(status = syncAction.status) is SyncAction.UpdateAccount -> syncState.copy(account = syncAction.account) + is SyncAction.UpdateAccountState -> syncState.copy(accountState = syncAction.accountState) is SyncAction.UpdateDeviceConstellation -> syncState.copy(constellationState = syncAction.deviceConstellation) } diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt index 4feda96e60..7674d8a8fc 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt @@ -10,12 +10,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthFlowError import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.DeviceConstellationObserver import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile +import mozilla.components.service.fxa.manager.AccountState import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.SCOPE_PROFILE import mozilla.components.service.fxa.sync.SyncStatusObserver import java.lang.Exception @@ -95,6 +98,24 @@ internal class FxaAccountObserver( private val autoPause: Boolean, private val coroutineScope: CoroutineScope, ) : AccountObserver { + override fun onReady(authenticatedAccount: OAuthAccount?) { + coroutineScope.launch { + if (authenticatedAccount == null) { + return@launch + } + + val syncAccount = + authenticatedAccount.getProfile()?.toAccount(authenticatedAccount) ?: return@launch + store.dispatch(SyncAction.UpdateAccount(account = syncAccount)) + + val accountState = when (authenticatedAccount.checkAuthorizationStatus(SCOPE_PROFILE)) { + true -> AccountState.Authenticated + null, false -> AccountState.AuthenticationProblem + } + store.dispatch(SyncAction.UpdateAccountState(accountState = accountState)) + } + } + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { coroutineScope.launch(Dispatchers.Main) { account.deviceConstellation().registerDeviceObserver( @@ -106,12 +127,23 @@ internal class FxaAccountObserver( coroutineScope.launch { val syncAccount = account.getProfile()?.toAccount(account) ?: return@launch store.dispatch(SyncAction.UpdateAccount(syncAccount)) + store.dispatch(SyncAction.UpdateAccountState(AccountState.Authenticated)) } } + override fun onAuthenticationProblems() { + store.dispatch(SyncAction.UpdateAccountState(accountState = AccountState.AuthenticationProblem)) + } + override fun onLoggedOut() { store.dispatch(SyncAction.UpdateSyncStatus(SyncStatus.LoggedOut)) store.dispatch(SyncAction.UpdateAccount(null)) + store.dispatch(SyncAction.UpdateAccountState(accountState = AccountState.NotAuthenticated)) + } + + override fun onFlowError(error: AuthFlowError) { + store.dispatch(SyncAction.UpdateAccount(account = null)) + store.dispatch(SyncAction.UpdateAccountState(accountState = AccountState.NotAuthenticated)) } } diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt index afc2fc3ee9..3c6a3421f2 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt @@ -74,13 +74,13 @@ internal class TestableStorageWrapper( manager: FxaAccountManager, accountEventObserverRegistry: ObserverRegistry<AccountEventsObserver>, serverConfig: FxaConfig, - private val block: () -> OAuthAccount = { - val account: OAuthAccount = mock() + private val block: () -> FirefoxAccount = { + val account: FirefoxAccount = mock() `when`(account.deviceConstellation()).thenReturn(mock()) account }, ) : StorageWrapper(manager, accountEventObserverRegistry, serverConfig) { - override fun obtainAccount(): OAuthAccount = block() + override fun obtainAccount(): FirefoxAccount = block() } // Same as the actual account manager, except we get to control how FirefoxAccountShaped instances @@ -95,8 +95,8 @@ internal open class TestableFxaAccountManager( syncConfig: SyncConfig? = null, coroutineContext: CoroutineContext, crashReporter: CrashReporting? = null, - block: () -> OAuthAccount = { - val account: OAuthAccount = mock() + block: () -> FirefoxAccount = { + val account: FirefoxAccount = mock() `when`(account.deviceConstellation()).thenReturn(mock()) account }, @@ -201,7 +201,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val manager = TestableFxaAccountManager( testContext, @@ -218,17 +218,18 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) manager.start() // Assert that persistence callback is set. - assertNotNull(account.persistenceCallback) + val captor = argumentCaptor<StatePersistenceCallback>() + verify(account).registerPersistenceCallback(captor.capture()) // Assert that ensureCapabilities fired, but not the device initialization (since we're restoring). verify(constellation).finalizeDevice(eq(AuthType.Existing), any()) // Assert that persistence callback is interacting with the storage layer. - account.persistenceCallback!!.persist("test") + captor.value.persist("test") verify(accountStorage).write("test") } @@ -237,7 +238,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val manager = TestableFxaAccountManager( testContext, @@ -254,17 +255,18 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) manager.start() // Assert that persistence callback is set. - assertNotNull(account.persistenceCallback) + val captor = argumentCaptor<StatePersistenceCallback>() + verify(account).registerPersistenceCallback(captor.capture()) // Assert that finalizeDevice fired with a correct auth type. 3 times since we re-try. verify(constellation, times(3)).finalizeDevice(eq(AuthType.Existing), any()) // Assert that persistence callback is interacting with the storage layer. - account.persistenceCallback!!.persist("test") + captor.value.persist("test") verify(accountStorage).write("test") // Since we weren't able to finalize the account state, we're no longer authenticated. @@ -276,7 +278,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation, ableToRecoverFromAuthError = false) + val account = statePersistenceTestableAccount(profile, constellation, ableToRecoverFromAuthError = false) val accountObserver: AccountObserver = mock() val manager = TestableFxaAccountManager( @@ -295,19 +297,19 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) assertFalse(manager.accountNeedsReauth()) - assertFalse(account.authErrorDetectedCalled) - assertFalse(account.checkAuthorizationStatusCalled) + verify(account, never()).authErrorDetected() + verify(account, never()).checkAuthorizationStatus(any()) verify(accountObserver, never()).onAuthenticationProblems() manager.start() assertTrue(manager.accountNeedsReauth()) verify(accountObserver, times(1)).onAuthenticationProblems() - assertTrue(account.authErrorDetectedCalled) - assertTrue(account.checkAuthorizationStatusCalled) + verify(account).authErrorDetected() + verify(account).checkAuthorizationStatus(any()) } @Test(expected = FxaPanicException::class) @@ -315,7 +317,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile("testUid", "test@example.com", null, "Test Profile") val constellation: DeviceConstellation = mock() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val accountObserver: AccountObserver = mock() val manager = TestableFxaAccountManager( @@ -339,7 +341,7 @@ class FxaAccountManagerTest { // We have an account at the start. `when`(accountStorage.read()).thenReturn(account) - assertNull(account.persistenceCallback) + verify(account, never()).registerPersistenceCallback(any()) assertFalse(manager.accountNeedsReauth()) verify(accountObserver, never()).onAuthenticationProblems() @@ -352,7 +354,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val accountObserver: AccountObserver = mock() // We are not using the "prepareHappy..." helper method here, because our account isn't a mock, // but an actual implementation of the interface. @@ -388,7 +390,9 @@ class FxaAccountManagerTest { verify(constellation).finalizeDevice(eq(AuthType.Signin), any()) // Assert that persistence callback is interacting with the storage layer. - account.persistenceCallback!!.persist("test") + val captor = argumentCaptor<StatePersistenceCallback>() + verify(account).registerPersistenceCallback(captor.capture()) + captor.value.persist("test") verify(accountStorage).write("test") } @@ -397,7 +401,7 @@ class FxaAccountManagerTest { val accountStorage: AccountStorage = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val constellation: DeviceConstellation = mockDeviceConstellation() - val account = StatePersistenceTestableAccount(profile, constellation) + val account = statePersistenceTestableAccount(profile, constellation) val accountObserver: AccountObserver = mock() // We are not using the "prepareHappy..." helper method here, because our account isn't a mock, // but an actual implementation of the interface. @@ -423,7 +427,7 @@ class FxaAccountManagerTest { manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", UNEXPECTED_AUTH_STATE)) assertTrue(manager.authenticatedAccount() == null) - // Start authentication. StatePersistenceTestableAccount will produce state=EXPECTED_AUTH_STATE. + // Start authentication. statePersistenceTestableAccount will produce state=EXPECTED_AUTH_STATE. assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) // Now attempt to finish it with a correct state. @@ -435,97 +439,17 @@ class FxaAccountManagerTest { assertEquals(account, manager.authenticatedAccount()) } - class StatePersistenceTestableAccount( - private val profile: Profile, - private val constellation: DeviceConstellation, - val ableToRecoverFromAuthError: Boolean = false, - val tokenServerEndpointUrl: String? = null, - val accessToken: (() -> AccessTokenInfo)? = null, - ) : OAuthAccount { - - var persistenceCallback: StatePersistenceCallback? = null - var checkAuthorizationStatusCalled = false - var authErrorDetectedCalled = false - - override suspend fun beginOAuthFlow(scopes: Set<String>, entryPoint: FxAEntryPoint): AuthFlowUrl? { - return AuthFlowUrl(EXPECTED_AUTH_STATE, testAuthFlowUrl(entrypoint = entryPoint.entryName).url) - } - - override suspend fun beginPairingFlow(pairingUrl: String, scopes: Set<String>, entryPoint: FxAEntryPoint): AuthFlowUrl? { - return AuthFlowUrl(EXPECTED_AUTH_STATE, testAuthFlowUrl(entrypoint = entryPoint.entryName).url) - } - - override suspend fun getProfile(ignoreCache: Boolean): Profile? { - return profile - } - - override fun getCurrentDeviceId(): String? { - return "testFxaDeviceId" - } - - override fun getSessionToken(): String? { - return null - } - - override suspend fun completeOAuthFlow(code: String, state: String): Boolean { - return true - } - - override suspend fun getAccessToken(singleScope: String): AccessTokenInfo? { - val token = accessToken?.invoke() - if (token != null) return token - - fail() - return null - } - - override fun authErrorDetected() { - authErrorDetectedCalled = true - } - - override suspend fun checkAuthorizationStatus(singleScope: String): Boolean? { - checkAuthorizationStatusCalled = true - return ableToRecoverFromAuthError - } - - override suspend fun getTokenServerEndpointURL(): String? { - if (tokenServerEndpointUrl != null) return tokenServerEndpointUrl - - fail() - return "" - } - - override suspend fun getManageAccountURL(entryPoint: FxAEntryPoint): String? { - return "https://firefox.com/settings" - } - - override fun getPairingAuthorityURL(): String { - return "https://firefox.com/pair" - } - - override fun registerPersistenceCallback(callback: StatePersistenceCallback) { - persistenceCallback = callback - } - - override fun deviceConstellation(): DeviceConstellation { - return constellation - } - - override suspend fun disconnect(): Boolean { - return true - } - - override fun toJSONString(): String { - fail() - return "" - } - - override fun close() { - // Only expect 'close' to be called if we can't recover from an auth error. - if (ableToRecoverFromAuthError) { - fail() - } - } + suspend fun statePersistenceTestableAccount(profile: Profile, constellation: DeviceConstellation, ableToRecoverFromAuthError: Boolean = false): FirefoxAccount { + val account = mock<FirefoxAccount>() + `when`(account.getProfile(anyBoolean())).thenReturn(profile) + `when`(account.deviceConstellation()).thenReturn(constellation) + `when`(account.checkAuthorizationStatus(any())).thenReturn(ableToRecoverFromAuthError) + `when`(account.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl(entrypoint = "home-menu")) + `when`(account.beginPairingFlow(any(), any(), any())).thenReturn(testAuthFlowUrl(entrypoint = "home-menu")) + `when`(account.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + `when`(account.getCurrentDeviceId()).thenReturn("testFxaDeviceId") + + return account } @Test @@ -596,7 +520,7 @@ class FxaAccountManagerTest { @Test fun `with persisted account and profile`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val profile = Profile( "testUid", @@ -667,7 +591,7 @@ class FxaAccountManagerTest { @Test fun `happy authentication and profile flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -712,7 +636,7 @@ class FxaAccountManagerTest { @Test(expected = FxaPanicException::class) fun `fxa panic during initDevice flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -740,7 +664,7 @@ class FxaAccountManagerTest { @Test(expected = FxaPanicException::class) fun `fxa panic during pairing flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() `when`(mockAccount.deviceConstellation()).thenReturn(mock()) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountStorage = mock<AccountStorage>() @@ -769,7 +693,7 @@ class FxaAccountManagerTest { @Test fun `happy pairing authentication and profile flow`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -806,7 +730,7 @@ class FxaAccountManagerTest { @Test fun `repeated unfinished authentication attempts succeed`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -863,7 +787,7 @@ class FxaAccountManagerTest { @Test fun `unhappy authentication flow`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountObserver: AccountObserver = mock() @@ -911,7 +835,7 @@ class FxaAccountManagerTest { @Test fun `unhappy pairing authentication flow`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") val accountObserver: AccountObserver = mock() @@ -970,7 +894,7 @@ class FxaAccountManagerTest { @Test fun `authentication issues are propagated via AccountObserver`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -1025,7 +949,7 @@ class FxaAccountManagerTest { @Test fun `authentication issues are recoverable via checkAuthorizationState`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -1072,7 +996,7 @@ class FxaAccountManagerTest { @Test fun `authentication recovery flow has a circuit breaker`() = runTest { - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") @@ -1163,7 +1087,7 @@ class FxaAccountManagerTest { @Test fun `unhappy profile fetching flow`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) @@ -1232,7 +1156,7 @@ class FxaAccountManagerTest { @Test fun `profile fetching flow hit an unrecoverable auth problem`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") @@ -1292,7 +1216,7 @@ class FxaAccountManagerTest { @Test fun `profile fetching flow hit an unrecoverable auth problem for which we can't determine a recovery state`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.deviceConstellation()).thenReturn(constellation) @@ -1353,7 +1277,7 @@ class FxaAccountManagerTest { @Test fun `profile fetching flow hit a recoverable auth problem`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() val captor = argumentCaptor<AuthType>() @@ -1437,7 +1361,7 @@ class FxaAccountManagerTest { @Test(expected = FxaPanicException::class) fun `profile fetching flow hit an fxa panic, which is re-thrown`() = runTest { val accountStorage = mock<AccountStorage>() - val mockAccount: OAuthAccount = mock() + val mockAccount: FirefoxAccount = mock() val constellation: DeviceConstellation = mock() `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") @@ -1564,7 +1488,7 @@ class FxaAccountManagerTest { } private suspend fun prepareHappyAuthenticationFlow( - mockAccount: OAuthAccount, + mockAccount: FirefoxAccount, profile: Profile, accountStorage: AccountStorage, accountObserver: AccountObserver, @@ -1608,7 +1532,7 @@ class FxaAccountManagerTest { } private suspend fun prepareUnhappyAuthenticationFlow( - mockAccount: OAuthAccount, + mockAccount: FirefoxAccount, profile: Profile, accountStorage: AccountStorage, accountObserver: AccountObserver, diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt index 6323861f0e..df37088ca7 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt @@ -83,8 +83,8 @@ class StateKtTest { private fun instantiateEvent(eventClassSimpleName: String): Event { return when (eventClassSimpleName) { "Start" -> Event.Account.Start - "BeginPairingFlow" -> Event.Account.BeginPairingFlow("http://some.pairing.url.com", mock()) - "BeginEmailFlow" -> Event.Account.BeginEmailFlow(mock()) + "BeginPairingFlow" -> Event.Account.BeginPairingFlow("http://some.pairing.url.com", mock(), mock()) + "BeginEmailFlow" -> Event.Account.BeginEmailFlow(mock(), mock()) "CancelAuth" -> Event.Progress.CancelAuth "StartedOAuthFlow" -> Event.Progress.StartedOAuthFlow("https://example.com/oauth-start") "AuthenticationError" -> Event.Account.AuthenticationError("fxa op") diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt index e0d90118fa..975fd6a481 100644 --- a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt @@ -12,13 +12,16 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import mozilla.components.concept.sync.AuthFlowError import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.Avatar import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.DeviceConstellation import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile +import mozilla.components.service.fxa.manager.AccountState import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.SCOPE_PROFILE import mozilla.components.support.test.any import mozilla.components.support.test.coMock import mozilla.components.support.test.eq @@ -26,9 +29,11 @@ import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.mock import mozilla.components.support.test.whenever import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` import java.lang.Exception @OptIn(ExperimentalCoroutinesApi::class) @@ -115,7 +120,7 @@ class SyncStoreSupportTest { } @Test - fun `GIVEN account observer WHEN onAuthenticated observed with profile THEN account state updated`() = coroutineScope.runTest { + fun `GIVEN account observer WHEN onAuthenticated observed with profile THEN account and account state are updated`() = coroutineScope.runTest { val profile = generateProfile() val constellation = mock<DeviceConstellation>() val account = coMock<OAuthAccount> { @@ -125,6 +130,8 @@ class SyncStoreSupportTest { whenever(getProfile()).thenReturn(profile) } + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + accountObserver.onAuthenticated(account, mock<AuthType.Existing>()) runCurrent() @@ -138,10 +145,11 @@ class SyncStoreSupportTest { ) store.waitUntilIdle() assertEquals(expected, store.state.account) + assertEquals(AccountState.Authenticated, store.state.accountState) } @Test - fun `GIVEN account observer WHEN onAuthenticated observed without profile THEN account not updated`() = coroutineScope.runTest { + fun `GIVEN account observer WHEN onAuthenticated observed without profile THEN account and account state are not updated`() = coroutineScope.runTest { val constellation = mock<DeviceConstellation>() val account = coMock<OAuthAccount> { whenever(deviceConstellation()).thenReturn(constellation) @@ -152,11 +160,12 @@ class SyncStoreSupportTest { runCurrent() store.waitUntilIdle() - assertEquals(null, store.state.account) + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) } @Test - fun `GIVEN user is logged in WHEN onLoggedOut observed THEN sync status and account updated`() = coroutineScope.runTest { + fun `GIVEN user is logged in WHEN onLoggedOut observed THEN sync status and account states are updated`() = coroutineScope.runTest { val account = coMock<OAuthAccount> { whenever(deviceConstellation()).thenReturn(mock()) whenever(getProfile()).thenReturn(null) @@ -169,7 +178,94 @@ class SyncStoreSupportTest { store.waitUntilIdle() assertEquals(SyncStatus.LoggedOut, store.state.status) - assertEquals(null, store.state.account) + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onAuthenticationProblems observed THEN account state is updated`() = coroutineScope.runTest { + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + accountObserver.onAuthenticationProblems() + runCurrent() + + store.waitUntilIdle() + assertEquals(AccountState.AuthenticationProblem, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onFlowError observed THEN account state is updated`() = coroutineScope.runTest { + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + accountObserver.onFlowError(mock<AuthFlowError>()) + runCurrent() + + store.waitUntilIdle() + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onReady observed with profile THEN account states are updated`() = coroutineScope.runTest { + val profile = generateProfile() + val currentDeviceId = "id" + val sessionToken = "token" + val constellation = mock<DeviceConstellation>() + val authenticatedAccount = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + whenever(getCurrentDeviceId()).thenReturn(currentDeviceId) + whenever(getSessionToken()).thenReturn(sessionToken) + whenever(getProfile()).thenReturn(profile) + } + val account = Account( + uid = profile.uid, + email = profile.email, + avatar = profile.avatar, + displayName = profile.displayName, + currentDeviceId = currentDeviceId, + sessionToken = sessionToken, + ) + + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + `when`(authenticatedAccount.checkAuthorizationStatus(eq(SCOPE_PROFILE))).thenReturn(false) + + accountObserver.onReady(authenticatedAccount = authenticatedAccount) + runCurrent() + + store.waitUntilIdle() + assertEquals(account, store.state.account) + assertEquals(AccountState.AuthenticationProblem, store.state.accountState) + + `when`(authenticatedAccount.checkAuthorizationStatus(eq(SCOPE_PROFILE))).thenReturn(true) + + accountObserver.onReady(authenticatedAccount = authenticatedAccount) + runCurrent() + + store.waitUntilIdle() + assertEquals(account, store.state.account) + assertEquals(AccountState.Authenticated, store.state.accountState) + } + + @Test + fun `GIVEN account observer WHEN onReady observed without profile THEN account states are not updated`() = coroutineScope.runTest { + val constellation = mock<DeviceConstellation>() + val account = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + whenever(getProfile()).thenReturn(null) + } + + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) + + accountObserver.onReady(account) + runCurrent() + + store.waitUntilIdle() + assertNull(store.state.account) + assertEquals(AccountState.NotAuthenticated, store.state.accountState) } @Test |