diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/BackupModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/BackupModule.kt index 1ccc4b276e..134fcdc1e4 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/BackupModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/BackupModule.kt @@ -67,4 +67,14 @@ class BackupModule { @Provides fun provideOnboardingBackupUseCase(backupScope: BackupScope) = backupScope.createUnEncryptedCopy + + @ViewModelScoped + @Provides + fun provideBackupAndUploadCryptoState(backupScope: BackupScope) = + backupScope.backupAndUploadCryptoState + + @ViewModelScoped + @Provides + fun provideSetLastDeviceIdUseCase(backupScope: BackupScope) = + backupScope.setLastDeviceId } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt index 72d47c0698..3bf7443cb4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.appLogger import com.wire.android.config.DefaultServerConfig import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.ClientScopeProvider @@ -40,25 +41,31 @@ import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.util.EMPTY import com.wire.android.util.deeplink.DeepLinkResult +import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.AuthenticationScope import com.wire.kalium.logic.feature.auth.DomainLookupUseCase +import com.wire.kalium.logic.feature.auth.IsNomadProfilesEnabledUseCase import com.wire.kalium.logic.feature.auth.ValidateEmailUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginResult import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult +import com.wire.kalium.logic.feature.backup.RestoreCryptoStateResult import com.wire.kalium.logic.feature.client.RegisterClientResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class LoginSSOViewModel( private val savedStateHandle: SavedStateHandle, @@ -68,7 +75,8 @@ class LoginSSOViewModel( clientScopeProviderFactory: ClientScopeProvider.Factory, userDataStoreProvider: UserDataStoreProvider, private val ssoExtension: LoginSSOViewModelExtension, - serverConfig: ServerConfig.Links + serverConfig: ServerConfig.Links, + private val dispatchers: DispatcherProvider, ) : LoginViewModel( savedStateHandle, clientScopeProviderFactory, @@ -90,6 +98,7 @@ class LoginSSOViewModel( userDataStoreProvider: UserDataStoreProvider, serverConfig: ServerConfig.Links, @DefaultWebSocketEnabledByDefault defaultWebSocketEnabledByDefault: Boolean, + dispatchers: DispatcherProvider, ) : this( savedStateHandle, addAuthenticatedUser, @@ -98,7 +107,8 @@ class LoginSSOViewModel( clientScopeProviderFactory, userDataStoreProvider, LoginSSOViewModelExtension(addAuthenticatedUser, coreLogic, defaultWebSocketEnabledByDefault), - serverConfig + serverConfig, + dispatchers, ) var openWebUrl = MutableSharedFlow>() @@ -237,16 +247,38 @@ class LoginSSOViewModel( onSSOLoginFailure = { updateSSOFlowState(it.toLoginError()) }, onAddAuthenticatedUserFailure = { updateSSOFlowState(it.toLoginError()) }, onSuccess = { storedUserId -> - registerClient(storedUserId, null).let { - when (it) { - is RegisterClientResult.Success -> + appLogger.i("$TAG SSO session established successfully for userId: $storedUserId, checking Nomad status") + val isNomadEnabled = withContext(dispatchers.io()) { + val result = coreLogic.getSessionScope(storedUserId).authenticationScope.isNomadProfilesEnabled() + result is IsNomadProfilesEnabledUseCase.Result.Success && result.isEnabled + } + if (!isNomadEnabled) { + appLogger.i("$TAG Nomad not enabled, proceeding with regular login") + registerClientAndUpdateState(storedUserId, setLastDeviceId = false) + } else { + appLogger.i("$TAG Nomad enabled, attempting crypto state restore") + when ( + withContext(dispatchers.io()) { + coreLogic.getSessionScope(storedUserId).backup.restoreCryptoState() + } + ) { + is RestoreCryptoStateResult.Success -> { updateSSOFlowState(LoginState.Success(isInitialSyncCompleted(storedUserId), false)) + } - is RegisterClientResult.Failure -> - updateSSOFlowState(it.toLoginError()) + is RestoreCryptoStateResult.NoBackupAvailable -> { + registerClientAndUpdateState(storedUserId, setLastDeviceId = true) + } - is RegisterClientResult.E2EICertificateRequired -> - updateSSOFlowState(LoginState.Success(isInitialSyncCompleted(storedUserId), true)) + is RestoreCryptoStateResult.Failure -> { + appLogger.e("$TAG Failed to restore crypto state during SSO login") + revertSSOSession(storedUserId) + updateSSOFlowState( + LoginState.Error.DialogError.GenericError( + CoreFailure.Unknown(Exception("Failed to restore crypto state")) + ) + ) + } } } } @@ -272,6 +304,37 @@ class LoginSSOViewModel( } } + private suspend fun registerClientAndUpdateState(userId: UserId, setLastDeviceId: Boolean = false) { + withContext(dispatchers.io()) { + registerClient(userId = userId, password = null) + }.let { + when (it) { + is RegisterClientResult.Success -> { + if (setLastDeviceId) { + coreLogic.getSessionScope(userId).backup.setLastDeviceId(it.client.id.value) + } + updateSSOFlowState(LoginState.Success(isInitialSyncCompleted(userId), false)) + } + + is RegisterClientResult.E2EICertificateRequired -> + updateSSOFlowState(LoginState.Success(isInitialSyncCompleted(userId), true)) + + is RegisterClientResult.Failure.TooManyClients -> + updateSSOFlowState(LoginState.Error.TooManyDevicesError) + + is RegisterClientResult.Failure -> { + revertSSOSession(userId) + updateSSOFlowState(it.toLoginError()) + } + } + } + } + + private suspend fun revertSSOSession(userId: UserId) { + coreLogic.getSessionScope(userId).logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) + coreLogic.getGlobalScope().deleteSession(userId) + } + private fun openWebUrl(url: String, customServerConfig: ServerConfig.Links) { viewModelScope.launch { updateSSOFlowState(LoginState.Default) @@ -281,6 +344,7 @@ class LoginSSOViewModel( companion object { const val SSO_CODE_SAVED_STATE_KEY = "sso_code" + private const val TAG = "[LoginSSOViewModel]" } private fun consumePendingNomadServiceUrl(): String? = pendingNomadServiceUrl.also { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/NomadAccountBlocksLoginDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/NomadAccountBlocksLoginDialog.kt index 271d2f02e0..cf91c3f802 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/NomadAccountBlocksLoginDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/NomadAccountBlocksLoginDialog.kt @@ -25,8 +25,10 @@ import com.wire.android.ui.common.VisibilityState import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.common.wireDialogPropertiesBuilder +import com.wire.android.ui.theme.WireTheme @Composable fun NomadAccountBlocksLoginDialog( @@ -50,3 +52,14 @@ fun NomadAccountBlocksLoginDialog( ) } } + +@Composable +@MultipleThemePreviews +fun PreviewNomadAccountBlocksLoginDialog() { + WireTheme { + NomadAccountBlocksLoginDialog( + dialogState = VisibilityState(true), + onActionButtonClicked = {} + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt index b43c028aae..af34706e79 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt @@ -49,13 +49,17 @@ import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.EnterpriseLoginResult +import com.wire.kalium.logic.feature.auth.IsNomadProfilesEnabledUseCase import com.wire.kalium.logic.feature.auth.LoginRedirectPath import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.auth.sso.FetchSSOSettingsUseCase import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginResult import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult +import com.wire.kalium.logic.feature.backup.RestoreCryptoStateResult import com.wire.kalium.logic.feature.client.RegisterClientResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.collectLatest @@ -141,24 +145,24 @@ class NewLoginViewModel( // Fetch default SSO code for the server configuration if (userIdentifierTextState.text.isEmpty() && preFilledUserIdentifier is PreFilledUserIdentifierType.None) { viewModelScope.launch(dispatchers.io()) { - appLogger.d("NewLoginViewModel: Fetching default SSO code for server") + appLogger.d("$TAG Fetching default SSO code for server") ssoExtension.fetchDefaultSSOCode( serverConfig = serverConfig, onAuthScopeFailure = { error -> - appLogger.e("NewLoginViewModel: Failed to create auth scope for SSO settings: $error") + appLogger.e("$TAG Failed to create auth scope for SSO settings: $error") }, onFetchSSOSettingsFailure = { error -> - appLogger.e("NewLoginViewModel: Failed to fetch SSO settings: $error") + appLogger.e("$TAG Failed to fetch SSO settings: $error") }, onSuccess = { defaultSSOCode -> if (defaultSSOCode != null && userIdentifierTextState.text.isEmpty()) { - appLogger.d("NewLoginViewModel: Successfully fetched default SSO code") + appLogger.d("$TAG Successfully fetched default SSO code") withContext(dispatchers.main()) { userIdentifierTextState.setTextAndPlaceCursorAtEnd(defaultSSOCode) savedStateHandle[USER_IDENTIFIER_SAVED_STATE_KEY] = defaultSSOCode } } else { - appLogger.d("NewLoginViewModel: No default SSO code configured for this server") + appLogger.d("$TAG No default SSO code configured for this server") } } ) @@ -263,7 +267,7 @@ class NewLoginViewModel( onAuthScopeFailure = { updateLoginFlowState(it.toLoginError()) }, onFetchSSOSettingsFailure = { updateLoginFlowState(it.toLoginError()) }, onSuccess = { defaultSSOCode -> - appLogger.d("NewLoginViewModel: Successfully fetched default SSO code") + appLogger.d("$TAG Successfully fetched default SSO code") when { defaultSSOCode != null -> { @@ -299,9 +303,7 @@ class NewLoginViewModel( ) } - fun handleSSOResult( - ssoLoginResult: DeepLinkResult.SSOLogin, - ) { + fun handleSSOResult(ssoLoginResult: DeepLinkResult.SSOLogin) { updateLoginFlowState(NewLoginFlowState.Loading) when (ssoLoginResult) { is DeepLinkResult.SSOLogin.Success -> { @@ -315,34 +317,38 @@ class NewLoginViewModel( onSSOLoginFailure = { updateLoginFlowState(it.toLoginError()) }, onAddAuthenticatedUserFailure = { updateLoginFlowState(it.toLoginError()) }, onSuccess = { storedUserId -> - loginExtension.registerClient(storedUserId, null).let { result -> - withContext(dispatchers.main()) { - when (result) { - is RegisterClientResult.Success -> { + val result = coreLogic.getSessionScope(storedUserId).authenticationScope.isNomadProfilesEnabled() + val isNomadEnabled = result is IsNomadProfilesEnabledUseCase.Result.Success && result.isEnabled + if (!isNomadEnabled) { + appLogger.i("$TAG Nomad not enabled, proceeding with regular login") + registerClientAndUpdateState(storedUserId, setLastDeviceId = false) + } else { + when ( + withContext(dispatchers.io()) { + coreLogic.getSessionScope(storedUserId).backup.restoreCryptoState() + } + ) { + is RestoreCryptoStateResult.Success -> { + withContext(dispatchers.main()) { when (loginExtension.isInitialSyncCompleted(storedUserId)) { true -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.None)) false -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync)) } updateLoginFlowState(NewLoginFlowState.Default) } - - is RegisterClientResult.E2EICertificateRequired -> { - sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.E2EIEnrollment)) - updateLoginFlowState(NewLoginFlowState.Default) - } - - is RegisterClientResult.Failure.TooManyClients -> { - sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.TooManyDevices)) - updateLoginFlowState(NewLoginFlowState.Default) - } - - is RegisterClientResult.Failure.Generic -> - updateLoginFlowState(NewLoginFlowState.Error.DialogError.GenericError(result.genericFailure)) - - is RegisterClientResult.Failure.InvalidCredentials, - is RegisterClientResult.Failure.PasswordAuthRequired -> { // for SSO login these should not happen - val failure = CoreFailure.Unknown(IllegalStateException(result::class.simpleName ?: "Unknown")) - updateLoginFlowState(NewLoginFlowState.Error.DialogError.GenericError(failure)) + } + is RestoreCryptoStateResult.NoBackupAvailable -> { + registerClientAndUpdateState(storedUserId, setLastDeviceId = true) + } + is RestoreCryptoStateResult.Failure -> { + appLogger.e("$TAG Failed to restore crypto state during SSO login") + revertSSOSession(storedUserId) + withContext(dispatchers.main()) { + updateLoginFlowState( + NewLoginFlowState.Error.DialogError.GenericError( + CoreFailure.Unknown(Exception("Failed to restore crypto state")) + ) + ) } } } @@ -351,13 +357,55 @@ class NewLoginViewModel( ) } } - is DeepLinkResult.SSOLogin.Failure -> { updateLoginFlowState(NewLoginFlowState.Error.DialogError.SSOResultFailure(ssoLoginResult.ssoError)) } } } + private suspend fun registerClientAndUpdateState(userId: UserId, setLastDeviceId: Boolean = false) { + loginExtension.registerClient(userId, null).let { result -> + if (setLastDeviceId && result is RegisterClientResult.Success) { + coreLogic.getSessionScope(userId).backup.setLastDeviceId(result.client.id.value) + } + withContext(dispatchers.main()) { + when (result) { + is RegisterClientResult.Success -> { + when (loginExtension.isInitialSyncCompleted(userId)) { + true -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.None)) + false -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync)) + } + updateLoginFlowState(NewLoginFlowState.Default) + } + + is RegisterClientResult.E2EICertificateRequired -> { + sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.E2EIEnrollment)) + updateLoginFlowState(NewLoginFlowState.Default) + } + + is RegisterClientResult.Failure.TooManyClients -> { + sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.TooManyDevices)) + updateLoginFlowState(NewLoginFlowState.Default) + } + + is RegisterClientResult.Failure.Generic -> + updateLoginFlowState(NewLoginFlowState.Error.DialogError.GenericError(result.genericFailure)) + + is RegisterClientResult.Failure.InvalidCredentials, + is RegisterClientResult.Failure.PasswordAuthRequired -> { // for SSO login these should not happen + val failure = CoreFailure.Unknown(IllegalStateException(result::class.simpleName ?: "Unknown")) + updateLoginFlowState(NewLoginFlowState.Error.DialogError.GenericError(failure)) + } + } + } + } + } + + private suspend fun revertSSOSession(userId: UserId) { + coreLogic.getSessionScope(userId).logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) + coreLogic.getGlobalScope().deleteSession(userId) + } + /** * Update the state based on the input. */ @@ -382,6 +430,10 @@ class NewLoginViewModel( private fun consumePendingCookieLabel(): String? = pendingCookieLabel.also { pendingCookieLabel = null } + + companion object { + private const val TAG = "[NewLoginViewModel]" + } } private fun AutoVersionAuthScopeUseCase.Result.Failure.toLoginError() = when (this) { diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt index 30deab1c47..863c694efe 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.authentication.login.sso import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.assertions.shouldBeInstanceOf import com.wire.android.assertions.shouldNotBeInstanceOf @@ -38,7 +39,6 @@ import com.wire.android.ui.authentication.login.LoginPasswordPath import com.wire.android.ui.authentication.login.LoginState import com.wire.android.ui.authentication.login.SSOCodeAutoLogin import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.EMPTY import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.deeplink.SSOFailureCodes @@ -48,10 +48,13 @@ import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.CommonApiVersionType import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.AuthenticationScope import com.wire.kalium.logic.feature.auth.DomainLookupUseCase +import com.wire.kalium.logic.feature.auth.IsNomadProfilesEnabledUseCase +import com.wire.kalium.logic.feature.auth.LogoutUseCase import com.wire.kalium.logic.feature.auth.ValidateEmailUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.auth.sso.FetchSSOSettingsUseCase @@ -59,9 +62,14 @@ import com.wire.kalium.logic.feature.auth.sso.GetSSOLoginSessionUseCase import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginResult import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginUseCase import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult +import com.wire.kalium.logic.feature.backup.RestoreCryptoStateResult +import com.wire.kalium.logic.feature.backup.RestoreCryptoStateUseCase +import com.wire.kalium.logic.feature.backup.SetLastDeviceIdResult +import com.wire.kalium.logic.feature.backup.SetLastDeviceIdUseCase import com.wire.kalium.logic.feature.client.ClientScope import com.wire.kalium.logic.feature.client.GetOrRegisterClientUseCase import com.wire.kalium.logic.feature.client.RegisterClientResult +import com.wire.kalium.logic.feature.session.DeleteSessionUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -749,6 +757,138 @@ class LoginSSOViewModelTest { onFetchSSOSettingsFailureSlot.captured.invoke(FetchSSOSettingsUseCase.Result.Failure(CoreFailure.Unknown(IOException()))) } + @Test + fun `given Nomad enabled and crypto state restored, when establishSSOSession succeeds with initial sync completed, then state is Success`() = + runTest { + val expectedCookie = "some-cookie" + val (arrangement, loginViewModel) = Arrangement() + .withEstablishSSOSession(expectedCookie) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.Success) + .withIsSyncCompletedReturning(true) + .arrange() + + loginViewModel.establishSSOSession(expectedCookie, SERVER_CONFIG.id) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.ssoExtension.establishSSOSession( + eq(expectedCookie), + eq(SERVER_CONFIG.id), + any(), + any(), + capture(onAuthScopeFailureSlot), + capture(onSSOLoginFailureSlot), + capture(onAddAuthenticatedUserFailureSlot), + capture(onSuccessEstablishSSOSessionSlot) + ) + } + onSuccessEstablishSSOSessionSlot.captured.invoke(TestUser.USER_ID) + loginViewModel.loginState.flowState.shouldBeInstanceOf().let { + it.initialSyncCompleted shouldBeEqualTo true + it.isE2EIRequired shouldBeEqualTo false + } + } + + @Test + fun `given Nomad enabled and crypto state restored, when establishSSOSession succeeds with initial sync not completed, then state is Success with sync pending`() = + runTest { + val expectedCookie = "some-cookie" + val (arrangement, loginViewModel) = Arrangement() + .withEstablishSSOSession(expectedCookie) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.Success) + .withIsSyncCompletedReturning(false) + .arrange() + + loginViewModel.establishSSOSession(expectedCookie, SERVER_CONFIG.id) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.ssoExtension.establishSSOSession( + eq(expectedCookie), + eq(SERVER_CONFIG.id), + any(), + any(), + capture(onAuthScopeFailureSlot), + capture(onSSOLoginFailureSlot), + capture(onAddAuthenticatedUserFailureSlot), + capture(onSuccessEstablishSSOSessionSlot) + ) + } + onSuccessEstablishSSOSessionSlot.captured.invoke(TestUser.USER_ID) + loginViewModel.loginState.flowState.shouldBeInstanceOf().let { + it.initialSyncCompleted shouldBeEqualTo false + it.isE2EIRequired shouldBeEqualTo false + } + } + + @Test + fun `given Nomad enabled and no backup available, when establishSSOSession succeeds, then register new client and set last device id`() = + runTest { + val expectedCookie = "some-cookie" + val (arrangement, loginViewModel) = Arrangement() + .withEstablishSSOSession(expectedCookie) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.NoBackupAvailable) + .withRegisterClientReturning(RegisterClientResult.Success(TestClient.CLIENT)) + .withIsSyncCompletedReturning(true) + .withSetLastDeviceIdForNewClientSuccess() + .arrange() + + loginViewModel.establishSSOSession(expectedCookie, SERVER_CONFIG.id) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.ssoExtension.establishSSOSession( + eq(expectedCookie), + eq(SERVER_CONFIG.id), + any(), + any(), + capture(onAuthScopeFailureSlot), + capture(onSSOLoginFailureSlot), + capture(onAddAuthenticatedUserFailureSlot), + capture(onSuccessEstablishSSOSessionSlot) + ) + } + onSuccessEstablishSSOSessionSlot.captured.invoke(TestUser.USER_ID) + coVerify(exactly = 1) { arrangement.getOrRegisterClientUseCase(any()) } + coVerify(exactly = 1) { arrangement.setLastDeviceIdUseCase(TestClient.CLIENT_ID.value) } + loginViewModel.loginState.flowState.shouldBeInstanceOf() + } + + @Test + fun `given Nomad enabled and restore failure, when establishSSOSession succeeds, then revert session and show generic error`() = + runTest { + val expectedCookie = "some-cookie" + val (arrangement, loginViewModel) = Arrangement() + .withEstablishSSOSession(expectedCookie) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.Failure) + .withRevertSSOSessionSuccess() + .arrange() + + loginViewModel.establishSSOSession(expectedCookie, SERVER_CONFIG.id) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.ssoExtension.establishSSOSession( + eq(expectedCookie), + eq(SERVER_CONFIG.id), + any(), + any(), + capture(onAuthScopeFailureSlot), + capture(onSSOLoginFailureSlot), + capture(onAddAuthenticatedUserFailureSlot), + capture(onSuccessEstablishSSOSessionSlot) + ) + } + onSuccessEstablishSSOSessionSlot.captured.invoke(TestUser.USER_ID) + coVerify(exactly = 1) { arrangement.logoutUseCase(LogoutReason.SELF_HARD_LOGOUT, true) } + coVerify(exactly = 1) { arrangement.deleteSessionUseCase(TestUser.USER_ID) } + loginViewModel.loginState.flowState.shouldBeInstanceOf() + } + private class Arrangement { @MockK @@ -793,6 +933,21 @@ class LoginSSOViewModelTest { @MockK lateinit var ssoExtension: LoginSSOViewModelExtension + @MockK + lateinit var isNomadProfilesEnabledUseCase: IsNomadProfilesEnabledUseCase + + @MockK + lateinit var restoreCryptoStateUseCase: RestoreCryptoStateUseCase + + @MockK + lateinit var logoutUseCase: LogoutUseCase + + @MockK + lateinit var deleteSessionUseCase: DeleteSessionUseCase + + @MockK + lateinit var setLastDeviceIdUseCase: SetLastDeviceIdUseCase + init { MockKAnnotations.init(this) mockUri() @@ -813,6 +968,12 @@ class LoginSSOViewModelTest { every { authenticationScope.ssoLoginScope.getLoginSession } returns getSSOLoginSessionUseCase every { coreLogic.versionedAuthenticationScope(any()) } returns autoVersionAuthScopeUseCase every { authenticationScope.ssoLoginScope.fetchSSOSettings } returns fetchSSOSettings + every { + coreLogic.getSessionScope(any()).authenticationScope.isNomadProfilesEnabled + } returns isNomadProfilesEnabledUseCase + coEvery { isNomadProfilesEnabledUseCase() } returns IsNomadProfilesEnabledUseCase.Result.Success(false) + every { coreLogic.getGlobalScope().deleteSession } returns deleteSessionUseCase + every { coreLogic.getSessionScope(any()).logout } returns logoutUseCase withFetchSSOSettings() } @@ -871,6 +1032,25 @@ class LoginSSOViewModelTest { every { validateEmailUseCase(any()) } returns result } + fun withNomadEnabled(enabled: Boolean) = apply { + coEvery { isNomadProfilesEnabledUseCase() } returns IsNomadProfilesEnabledUseCase.Result.Success(enabled) + } + + fun withRestoreCryptoStateReturning(result: RestoreCryptoStateResult) = apply { + every { coreLogic.getSessionScope(any()).backup.restoreCryptoState } returns restoreCryptoStateUseCase + coEvery { restoreCryptoStateUseCase() } returns result + } + + fun withSetLastDeviceIdForNewClientSuccess() = apply { + every { coreLogic.getSessionScope(any()).backup.setLastDeviceId } returns setLastDeviceIdUseCase + coEvery { setLastDeviceIdUseCase(any()) } returns SetLastDeviceIdResult.Success + } + + fun withRevertSSOSessionSuccess() = apply { + coEvery { logoutUseCase(any(), any()) } returns Unit + coEvery { deleteSessionUseCase(any()) } returns DeleteSessionUseCase.Result.Success + } + fun withNomadAutoLogin(nomadServiceUrl: String) = apply { every { savedStateHandle.navArgs() } returns LoginNavArgs( loginPasswordPath = LoginPasswordPath(SERVER_CONFIG.links), @@ -891,6 +1071,7 @@ class LoginSSOViewModelTest { userDataStoreProvider = userDataStoreProvider, serverConfig = SERVER_CONFIG.links, ssoExtension = ssoExtension, + dispatchers = TestDispatcherProvider(), ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt index 600e364d87..14d3a74ac9 100644 --- a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt @@ -3,6 +3,7 @@ package com.wire.android.ui.newauthentication.login import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension @@ -18,7 +19,6 @@ import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType import com.wire.android.ui.authentication.login.SSOCodeAutoLogin import com.wire.android.ui.authentication.login.sso.LoginSSOViewModelExtension import com.wire.android.ui.authentication.login.sso.SSOUrlConfig -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.newauthentication.login.ValidateEmailOrSSOCodeUseCase.Result.ValidEmail import com.wire.android.util.EMPTY import com.wire.android.util.deeplink.DeepLinkResult @@ -27,17 +27,25 @@ import com.wire.android.util.newServerConfig import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.AuthenticationScope import com.wire.kalium.logic.feature.auth.EnterpriseLoginResult +import com.wire.kalium.logic.feature.auth.IsNomadProfilesEnabledUseCase import com.wire.kalium.logic.feature.auth.LoginRedirectPath +import com.wire.kalium.logic.feature.auth.LogoutUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.auth.sso.FetchSSOSettingsUseCase import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginResult import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult import com.wire.kalium.logic.feature.auth.sso.ValidateSSOCodeUseCase.Companion.SSO_CODE_WIRE_PREFIX +import com.wire.kalium.logic.feature.backup.RestoreCryptoStateResult +import com.wire.kalium.logic.feature.backup.RestoreCryptoStateUseCase +import com.wire.kalium.logic.feature.backup.SetLastDeviceIdResult +import com.wire.kalium.logic.feature.backup.SetLastDeviceIdUseCase import com.wire.kalium.logic.feature.client.RegisterClientResult +import com.wire.kalium.logic.feature.session.DeleteSessionUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -629,6 +637,21 @@ class NewLoginViewModelTest { val validateEmailOrSSOCodeUseCase: ValidateEmailOrSSOCodeUseCase = mockk() + @MockK + lateinit var isNomadProfilesEnabledUseCase: IsNomadProfilesEnabledUseCase + + @MockK + lateinit var restoreCryptoStateUseCase: RestoreCryptoStateUseCase + + @MockK + lateinit var logoutUseCase: LogoutUseCase + + @MockK + lateinit var deleteSessionUseCase: DeleteSessionUseCase + + @MockK + lateinit var setLastDeviceIdUseCase: SetLastDeviceIdUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) every { @@ -640,6 +663,12 @@ class NewLoginViewModelTest { every { savedStateHandle.navArgs() } returns LoginNavArgs() + every { + coreLogic.getSessionScope(any()).authenticationScope.isNomadProfilesEnabled + } returns isNomadProfilesEnabledUseCase + coEvery { isNomadProfilesEnabledUseCase() } returns IsNomadProfilesEnabledUseCase.Result.Success(false) + every { coreLogic.getGlobalScope().deleteSession } returns deleteSessionUseCase + every { coreLogic.getSessionScope(any()).logout } returns logoutUseCase } fun withNavArgsServerConfig(serverConfig: ServerConfig.Links) = apply { @@ -825,6 +854,25 @@ class NewLoginViewModelTest { ) } + fun withNomadEnabled(enabled: Boolean) = apply { + coEvery { isNomadProfilesEnabledUseCase() } returns IsNomadProfilesEnabledUseCase.Result.Success(enabled) + } + + fun withRestoreCryptoStateReturning(result: RestoreCryptoStateResult) = apply { + every { coreLogic.getSessionScope(any()).backup.restoreCryptoState } returns restoreCryptoStateUseCase + coEvery { restoreCryptoStateUseCase() } returns result + } + + fun withSetLastDeviceIdForNewClientSuccess() = apply { + every { coreLogic.getSessionScope(any()).backup.setLastDeviceId } returns setLastDeviceIdUseCase + coEvery { setLastDeviceIdUseCase(any()) } returns SetLastDeviceIdResult.Success + } + + fun withRevertSSOSessionSuccess() = apply { + coEvery { logoutUseCase(any(), any()) } returns Unit + coEvery { deleteSessionUseCase(any()) } returns DeleteSessionUseCase.Result.Success + } + private var defaultSSOCodeConfig: String = String.EMPTY fun arrange() = this to NewLoginViewModel( @@ -1093,6 +1141,95 @@ class NewLoginViewModelTest { assertEquals(NewLoginFlowState.CustomConfigDialog(customServerConfig), viewModel.state.flowState) } + @Test + fun `given Nomad enabled and crypto state restored, when handling SSO result with initial sync completed, then send NextStep-None action`() = + runTest(dispatchers.main()) { + val ssoDeepLinkResult = DeepLinkResult.SSOLogin.Success("cookie", "server-config-id") + val userId = UserId("user-id", "domain") + val (_, viewModel) = Arrangement() + .withEstablishSSOSessionSuccess(userId) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.Success) + .withIsInitialSyncCompletedReturning(true) + .arrange() + + viewModel.actions.test { + viewModel.handleSSOResult(ssoDeepLinkResult) + advanceUntilIdle() + + assertEquals(NewLoginAction.Success(NewLoginAction.Success.NextStep.None), expectMostRecentItem()) + assertEquals(NewLoginFlowState.Default, viewModel.state.flowState) + } + } + + @Test + fun `given Nomad enabled and crypto state restored, when handling SSO result with initial sync not completed, then send NextStep-InitialSync action`() = + runTest(dispatchers.main()) { + val ssoDeepLinkResult = DeepLinkResult.SSOLogin.Success("cookie", "server-config-id") + val userId = UserId("user-id", "domain") + val (_, viewModel) = Arrangement() + .withEstablishSSOSessionSuccess(userId) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.Success) + .withIsInitialSyncCompletedReturning(false) + .arrange() + + viewModel.actions.test { + viewModel.handleSSOResult(ssoDeepLinkResult) + advanceUntilIdle() + + assertEquals(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync), expectMostRecentItem()) + } + } + + @Test + fun `given Nomad enabled and no backup available, when handling SSO result, then register new client and set last device id`() = + runTest(dispatchers.main()) { + val ssoDeepLinkResult = DeepLinkResult.SSOLogin.Success("cookie", "server-config-id") + val userId = UserId("user-id", "domain") + val (arrangement, viewModel) = Arrangement() + .withEstablishSSOSessionSuccess(userId) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.NoBackupAvailable) + .withRegisterClientReturning(RegisterClientResult.Success(TestClient.CLIENT)) + .withIsInitialSyncCompletedReturning(true) + .withSetLastDeviceIdForNewClientSuccess() + .arrange() + + viewModel.handleSSOResult(ssoDeepLinkResult) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.loginViewModelExtension.registerClient(userId, any(), any(), any()) + } + coVerify(exactly = 1) { + arrangement.setLastDeviceIdUseCase(TestClient.CLIENT_ID.value) + } + } + + @Test + fun `given Nomad enabled and restore failure, when handling SSO result, then revert session and show generic error`() = + runTest(dispatchers.main()) { + val ssoDeepLinkResult = DeepLinkResult.SSOLogin.Success("cookie", "server-config-id") + val userId = UserId("user-id", "domain") + val (arrangement, viewModel) = Arrangement() + .withEstablishSSOSessionSuccess(userId) + .withNomadEnabled(true) + .withRestoreCryptoStateReturning(RestoreCryptoStateResult.Failure) + .withRevertSSOSessionSuccess() + .arrange() + + viewModel.actions.test { + viewModel.handleSSOResult(ssoDeepLinkResult) + advanceUntilIdle() + + expectNoEvents() + assertInstanceOf(viewModel.state.flowState) + coVerify(exactly = 1) { arrangement.logoutUseCase(LogoutReason.SELF_HARD_LOGOUT, true) } + coVerify(exactly = 1) { arrangement.deleteSessionUseCase(userId) } + } + } + companion object { private const val SSO_CODE_WITHOUT_PREFIX: String = "fd994b20-b9af-11ec-ae36-00163e9b33ca" private const val SSO_CODE_WITH_PREFIX: String = "$SSO_CODE_WIRE_PREFIX$SSO_CODE_WITHOUT_PREFIX" diff --git a/kalium b/kalium index 08d396ece0..fa4985de37 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 08d396ece058c5a3a2a77c96190916b8d2b5c06d +Subproject commit fa4985de37c18c6333a3345d1796501cfb11c67a