diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 59c240733..8db178338 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.MotionEvent import android.view.WindowManager +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -76,6 +77,15 @@ class MainActivity : AppCompatActivity() { is MainEvent.CompleteAutofill -> handleCompleteAutofill(event) MainEvent.Recreate -> handleRecreate() MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen() + is MainEvent.ShowToast -> { + Toast + .makeText( + baseContext, + event.message.invoke(resources), + Toast.LENGTH_SHORT, + ) + .show() + } } } updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed) diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 7d2e05346..d76672d05 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager @@ -17,11 +18,15 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager +import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut @@ -56,6 +61,7 @@ class MainViewModel @Inject constructor( settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, private val authRepository: AuthRepository, + private val environmentRepository: EnvironmentRepository, private val savedStateHandle: SavedStateHandle, private val clock: Clock, ) : BaseViewModel<MainState, MainEvent, MainAction>( @@ -226,14 +232,7 @@ class MainViewModel @Inject constructor( } completeRegistrationData != null -> { - if (authRepository.activeUserId != null) { - authRepository.hasPendingAccountAddition = true - } - specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.PreLogin.CompleteRegistration( - completeRegistrationData = completeRegistrationData, - timestamp = clock.millis(), - ) + handleCompleteRegistrationData(completeRegistrationData) } autofillSaveItem != null -> { @@ -310,6 +309,47 @@ class MainViewModel @Inject constructor( sendEvent(MainEvent.Recreate) garbageCollectionManager.tryCollect() } + + private fun handleCompleteRegistrationData(data: CompleteRegistrationData) { + viewModelScope.launch { + // Attempt to load the environment for the user if they have a pre-auth environment + // saved. + environmentRepository.loadEnvironmentForEmail(userEmail = data.email) + // Determine if the token is still valid. + val emailTokenResult = authRepository.validateEmailToken( + email = data.email, + token = data.verificationToken, + ) + when (emailTokenResult) { + is EmailTokenResult.Error -> { + sendEvent( + MainEvent.ShowToast( + message = emailTokenResult + .message + ?.asText() + ?: R.string.there_was_an_issue_validating_the_registration_token + .asText(), + ), + ) + } + EmailTokenResult.Expired -> { + specialCircumstanceManager.specialCircumstance = SpecialCircumstance + .RegistrationEvent + .ExpiredRegistrationLink + } + EmailTokenResult.Success -> { + if (authRepository.activeUserId != null) { + authRepository.hasPendingAccountAddition = true + } + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.RegistrationEvent.CompleteRegistration( + completeRegistrationData = data, + timestamp = clock.millis(), + ) + } + } + } + } } /** @@ -396,4 +436,9 @@ sealed class MainEvent { * Navigate to the debug menu. */ data object NavigateToDebugMenu : MainEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : MainEvent() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt index 547add087..343a86f6a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson import kotlinx.serialization.json.JsonPrimitive import retrofit2.Call import retrofit2.http.Body @@ -79,4 +80,9 @@ interface UnauthenticatedIdentityApi { suspend fun sendVerificationEmail( @Body body: SendVerificationEmailRequestJson, ): Result<JsonPrimitive?> + + @POST("/accounts/register/verification-email-clicked") + suspend fun verifyEmailToken( + @Body body: VerifyEmailTokenRequestJson, + ): Result<Unit> } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt new file mode 100644 index 000000000..673f02971 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models the request body for verify email token endpoint. + * + * @param email the email address of the user to verify. + * @param token the provided email verification token. + */ +@Serializable +data class VerifyEmailTokenRequestJson( + @SerialName("email") + val email: String, + @SerialName("emailVerificationToken") + val token: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt new file mode 100644 index 000000000..a43bafeea --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Model the response of a verify email token request. + * + * A valid response will be a [VerifyEmailTokenResponseJson.Valid] + * + * an invalid response will be a [VerifyEmailTokenResponseJson.Invalid] with a message. + */ +@Serializable +sealed class VerifyEmailTokenResponseJson { + + /** + * The token is confirmed as valid from the response. + */ + @Serializable + data object Valid : VerifyEmailTokenResponseJson() + + /** + * The response is invalid. + * + * @property message The error message. Expected to explain the reason why the token is invalid. + */ + @Serializable + data class Invalid( + @SerialName("message") + val message: String, + ) : VerifyEmailTokenResponseJson() + + /** + * The token has expired. This is special case of similar to [Invalid]. + */ + @Serializable + data object TokenExpired : VerifyEmailTokenResponseJson() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt index 1c2ada0dc..09bd21c38 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt @@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson /** * Provides an API for querying identity endpoints. @@ -72,4 +74,12 @@ interface IdentityService { * Register a new account to Bitwarden using email verification flow. */ suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson> + + /** + * Makes request to verify email registration token. If the token provided is + * still valid will return success. + */ + suspend fun verifyEmailRegistrationToken( + body: VerifyEmailTokenRequestJson, + ): Result<VerifyEmailTokenResponseJson> } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt index 1132ac8b8..7a32b598a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt @@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode import com.x8bit.bitwarden.data.platform.datasource.network.util.executeForResult @@ -127,4 +129,35 @@ class IdentityServiceImpl( .sendVerificationEmail(body = body) .map { it?.content } } + + override suspend fun verifyEmailRegistrationToken( + body: VerifyEmailTokenRequestJson, + ): Result<VerifyEmailTokenResponseJson> = unauthenticatedIdentityApi + .verifyEmailToken( + body = body, + ) + .map { + VerifyEmailTokenResponseJson.Valid + } + .recoverCatching { throwable -> + val bitwardenError = throwable.toBitwardenError() + bitwardenError + .parseErrorBodyOrNull<VerifyEmailTokenResponseJson.Invalid>( + code = 400, + json = json, + ) + ?.checkForExpiredMessage() + ?: throw throwable + } } + +/** + * If the message body contains text related to the token being expired, return + * the TokenExpired type. Otherwise, return the original Invalid response. + */ +private fun VerifyEmailTokenResponseJson.Invalid.checkForExpiredMessage() = + if (message.contains(other = "expired", ignoreCase = true)) { + VerifyEmailTokenResponseJson.TokenExpired + } else { + this + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt index 60f6d8dba..71b825ade 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt @@ -10,7 +10,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult import kotlinx.coroutines.flow.Flow /** - * A manager class for handling authentication fo logging in with remote device. + * A manager class for handling authentication for logging in with remote device. */ interface AuthRequestManager { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 0c77c36c9..2bae54c03 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult @@ -377,4 +378,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { name: String, receiveMarketingEmails: Boolean, ): SendVerificationEmailResult + + /** + * Validates the given [token] for the given [email]. Part of th new account registration flow. + */ + suspend fun validateEmailToken( + email: String, + token: String, + ): EmailTokenResult } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index e969c1fed..dcca27efe 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -26,6 +26,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService @@ -41,6 +43,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult @@ -1256,6 +1259,31 @@ class AuthRepositoryImpl( }, ) + override suspend fun validateEmailToken(email: String, token: String): EmailTokenResult { + return identityService + .verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + email = email, + token = token, + ), + ) + .fold( + onSuccess = { + when (val json = it) { + VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success + is VerifyEmailTokenResponseJson.Invalid -> { + EmailTokenResult.Error(json.message) + } + + VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired + } + }, + onFailure = { + EmailTokenResult.Error(message = null) + }, + ) + } + @Suppress("CyclomaticComplexMethod") private suspend fun validatePasswordAgainstPolicy( password: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt new file mode 100644 index 000000000..398904536 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Model the result of a request to validate a given email token. + */ +sealed class EmailTokenResult { + + /** + * The token is valid and the user can proceed with account creation. + */ + data object Success : EmailTokenResult() + + /** + * The token has expired and is no longer valid. + */ + data object Expired : EmailTokenResult() + + /** + * There was an error validating the token. + */ + data class Error(val message: String?) : EmailTokenResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt index c35b46feb..486eb5dbd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt @@ -26,7 +26,7 @@ class SpecialCircumstanceManagerImpl( it?.activeAccount?.isLoggedIn == true } .onEach { _ -> - if (specialCircumstance is SpecialCircumstance.PreLogin) { + if (specialCircumstance is SpecialCircumstance.RegistrationEvent) { specialCircumstance = null } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 24a4a60a3..f8c376f34 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import kotlinx.parcelize.Parcelize -import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager /** * Represents a special circumstance the app may be in. These circumstances could require some kind @@ -93,11 +92,9 @@ sealed class SpecialCircumstance : Parcelable { /** * A subset of [SpecialCircumstance] that are only relevant in a pre-login state and should be * cleared after a successful login. - * - * @see [SpecialCircumstanceManager.clearSpecialCircumstanceAfterLogin] */ @Parcelize - sealed class PreLogin : SpecialCircumstance() { + sealed class RegistrationEvent : SpecialCircumstance() { /** * The app was launched via AppLink in order to allow the user complete an ongoing * registration. @@ -106,6 +103,13 @@ sealed class SpecialCircumstance : Parcelable { data class CompleteRegistration( val completeRegistrationData: CompleteRegistrationData, val timestamp: Long, - ) : PreLogin() + ) : RegistrationEvent() + + /** + * The app was launched via AppLink in order to allow the user to complete registration but, + * the registration link has expired. + */ + @Parcelize + data object ExpiredRegistrationLink : RegistrationEvent() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index befdf9770..a8a737225 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -21,7 +21,8 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? = is SpecialCircumstance.Fido2Save -> null is SpecialCircumstance.Fido2Assertion -> null is SpecialCircumstance.Fido2GetCredentials -> null - is SpecialCircumstance.PreLogin.CompleteRegistration -> null + is SpecialCircumstance.RegistrationEvent.CompleteRegistration -> null + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink -> null } /** @@ -38,7 +39,8 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData? is SpecialCircumstance.Fido2Save -> null is SpecialCircumstance.Fido2Assertion -> null is SpecialCircumstance.Fido2GetCredentials -> null - is SpecialCircumstance.PreLogin.CompleteRegistration -> null + is SpecialCircumstance.RegistrationEvent.CompleteRegistration -> null + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink -> null } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt index 38557b848..abb663e35 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -52,6 +53,13 @@ fun ExpiredRegistrationLinkScreen( } } } + val sendCloseClicked = remember(viewModel) { + { + viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) + } + } + + BackHandler(onBack = sendCloseClicked) BitwardenScaffold( topBar = { @@ -61,11 +69,7 @@ fun ExpiredRegistrationLinkScreen( navigationIcon = NavigationIcon( navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), navigationIconContentDescription = stringResource(id = R.string.close), - onNavigationIconClick = remember(viewModel) { - { - viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) - } - }, + onNavigationIconClick = sendCloseClicked, ), ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt index 36c28e75a..60c98edd9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt @@ -1,13 +1,17 @@ package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject /** * View model for the [ExpiredRegistrationLinkScreen]. */ -class ExpiredRegistrationLinkViewModel @Inject constructor() : - BaseViewModel<Unit, ExpiredRegistrationLinkEvent, ExpiredRegistrationLinkAction>( +@HiltViewModel +class ExpiredRegistrationLinkViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : BaseViewModel<Unit, ExpiredRegistrationLinkEvent, ExpiredRegistrationLinkAction>( initialState = Unit, ) { override fun handleAction(action: ExpiredRegistrationLinkAction) { @@ -21,16 +25,28 @@ class ExpiredRegistrationLinkViewModel @Inject constructor() : } private fun handleRestartRegistrationClicked() { + resetPendingAccountAddition() sendEvent(ExpiredRegistrationLinkEvent.NavigateToStartRegistration) } private fun handleGoToLoginClicked() { + resetPendingAccountAddition() sendEvent(ExpiredRegistrationLinkEvent.NavigateToLogin) } private fun handleCloseClicked() { + resetPendingAccountAddition() sendEvent(ExpiredRegistrationLinkEvent.NavigateBack) } + + /** + * Since leaving the expired registration screen takes the user back to the landing + * screen we want to update the [AuthRepository] to add a pending account addition. + * This will help ensure we are in the correct user state when returning to the landing screen. + */ + private fun resetPendingAccountAddition() { + authRepository.hasPendingAccountAddition = true + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index dd2b5f831..8d2edc9bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -70,13 +70,8 @@ class RootNavViewModel @Inject constructor( userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword - specialCircumstance is SpecialCircumstance.PreLogin.CompleteRegistration -> { - RootNavState.CompleteOngoingRegistration( - email = specialCircumstance.completeRegistrationData.email, - verificationToken = specialCircumstance.completeRegistrationData.verificationToken, - fromEmail = specialCircumstance.completeRegistrationData.fromEmail, - timestamp = specialCircumstance.timestamp, - ) + specialCircumstance is SpecialCircumstance.RegistrationEvent -> { + getRegistrationEventNavState(specialCircumstance) } userState == null || @@ -141,7 +136,7 @@ class RootNavViewModel @Inject constructor( null, -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) - is SpecialCircumstance.PreLogin.CompleteRegistration -> { + is SpecialCircumstance.RegistrationEvent -> { throw IllegalStateException( "Special circumstance should have been already handled.", ) @@ -154,6 +149,23 @@ class RootNavViewModel @Inject constructor( mutableStateFlow.update { updatedRootNavState } } + private fun getRegistrationEventNavState( + registrationEvent: SpecialCircumstance.RegistrationEvent, + ): RootNavState = when (registrationEvent) { + is SpecialCircumstance.RegistrationEvent.CompleteRegistration -> { + RootNavState.CompleteOngoingRegistration( + email = registrationEvent.completeRegistrationData.email, + verificationToken = registrationEvent.completeRegistrationData.verificationToken, + fromEmail = registrationEvent.completeRegistrationData.fromEmail, + timestamp = registrationEvent.timestamp, + ) + } + + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink -> { + RootNavState.ExpiredRegistrationLink + } + } + private fun UserState.shouldShowRemovePassword(authState: AuthState): Boolean { val isLoggedInUsingSso = (authState as? AuthState.Authenticated) ?.accessToken diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e86ab8c3a..7449ded51 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -994,4 +994,5 @@ Do you want to switch to this account?</string> <string name="restart_registration">Restart registration</string> <string name="authenticator_sync">Authenticator Sync</string> <string name="allow_bitwarden_authenticator_syncing">Allow Bitwarden Authenticator Syncing</string> + <string name="there_was_an_issue_validating_the_registration_token">There was an issue validating the registration token.</string> </resources> diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 99af228b3..3f14b2d67 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull @@ -34,12 +35,14 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut @@ -81,6 +84,7 @@ class MainViewModelTest : BaseViewModelTest() { every { activeUserId } returns DEFAULT_USER_STATE.activeUserId every { userStateFlow } returns mutableUserStateFlow every { switchAccount(any()) } returns SwitchAccountResult.NoChange + coEvery { validateEmailToken(any(), any()) } returns EmailTokenResult.Success } private val mutableVaultStateEventFlow = bufferedMutableSharedFlow<VaultStateEvent>() private val vaultRepository = mockk<VaultRepository> { @@ -95,6 +99,9 @@ class MainViewModelTest : BaseViewModelTest() { authRepository = mockAuthRepository, dispatcherManager = FakeDispatcherManager(), ) + private val environmentRepository = mockk<EnvironmentRepository>(relaxed = true) { + every { loadEnvironmentForEmail(any()) } returns true + } private val intentManager: IntentManager = mockk { every { getShareDataFromIntent(any()) } returns null } @@ -326,9 +333,12 @@ class MainViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to CompleteRegistration`() { + fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to CompleteRegistration if token is valid`() { val viewModel = createViewModel() - val completeRegistrationData = mockk<CompleteRegistrationData>() + val completeRegistrationData = mockk<CompleteRegistrationData> { + every { email } returns "email" + every { verificationToken } returns "token" + } val mockIntent = mockk<Intent> { every { getPasswordlessRequestDataIntentOrNull() } returns null every { getAutofillSaveItemOrNull() } returns null @@ -346,14 +356,173 @@ class MainViewModelTest : BaseViewModelTest() { ), ) assertEquals( - SpecialCircumstance.PreLogin.CompleteRegistration( + SpecialCircumstance.RegistrationEvent.CompleteRegistration( completeRegistrationData = completeRegistrationData, timestamp = FIXED_CLOCK.millis(), ), specialCircumstanceManager.specialCircumstance, ) + + verify(exactly = 0) { authRepository.hasPendingAccountAddition = true } } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with complete registration data should set pending account addition to true if there is an active user`() { + val viewModel = createViewModel() + val completeRegistrationData = mockk<CompleteRegistrationData> { + every { email } returns "email" + every { verificationToken } returns "token" + } + val mockIntent = mockk<Intent> { + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData + every { getAutofillSelectionDataOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + every { authRepository.activeUserId } returns "activeId" + every { authRepository.hasPendingAccountAddition = true } just runs + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + assertEquals( + SpecialCircumstance.RegistrationEvent.CompleteRegistration( + completeRegistrationData = completeRegistrationData, + timestamp = FIXED_CLOCK.millis(), + ), + specialCircumstanceManager.specialCircumstance, + ) + verify { authRepository.hasPendingAccountAddition = true } + } + + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to ExpiredRegistration if token is not valid`() { + val viewModel = createViewModel() + val intentEmail = "email" + val token = "token" + val completeRegistrationData = mockk<CompleteRegistrationData> { + every { email } returns intentEmail + every { verificationToken } returns token + } + val mockIntent = mockk<Intent> { + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData + every { getAutofillSelectionDataOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + every { authRepository.activeUserId } returns null + coEvery { + authRepository.validateEmailToken( + email = intentEmail, + token = token, + ) + } returns EmailTokenResult.Expired + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + assertEquals( + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink, + specialCircumstanceManager.specialCircumstance, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with complete registration data should show toast if token is not valid but unable to determine reason`() = + runTest { + val viewModel = createViewModel() + val intentEmail = "email" + val token = "token" + val completeRegistrationData = mockk<CompleteRegistrationData> { + every { email } returns intentEmail + every { verificationToken } returns token + } + val mockIntent = mockk<Intent> { + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData + every { getAutofillSelectionDataOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + every { authRepository.activeUserId } returns null + coEvery { + authRepository.validateEmailToken( + intentEmail, + token, + ) + } returns EmailTokenResult.Error(message = null) + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + viewModel.eventFlow.test { + assertEquals( + MainEvent.ShowToast(R.string.there_was_an_issue_validating_the_registration_token.asText()), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with complete registration data should show toast with custom message if token is not valid but unable to determine reason`() = + runTest { + val viewModel = createViewModel() + val intentEmail = "email" + val token = "token" + val completeRegistrationData = mockk<CompleteRegistrationData> { + every { email } returns intentEmail + every { verificationToken } returns token + } + val mockIntent = mockk<Intent> { + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData + every { getAutofillSelectionDataOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + every { authRepository.activeUserId } returns null + + val expectedMessage = "expectedMessage" + coEvery { + authRepository.validateEmailToken( + intentEmail, + token, + ) + } returns EmailTokenResult.Error(message = expectedMessage) + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + viewModel.eventFlow.test { + assertEquals( + MainEvent.ShowToast(expectedMessage.asText()), + awaitItem(), + ) + } + } + @Suppress("MaxLineLength") @Test fun `on ReceiveFirstIntent with an autofill save item should set the special circumstance to AutofillSave`() { @@ -804,6 +973,7 @@ class MainViewModelTest : BaseViewModelTest() { vaultRepository = vaultRepository, authRepository = authRepository, clock = FIXED_CLOCK, + environmentRepository = environmentRepository, savedStateHandle = savedStateHandle.apply { set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance) }, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt index a10b77c6f..4a5e11dd1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt @@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEm import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider import com.x8bit.bitwarden.data.platform.util.asSuccess @@ -369,6 +371,77 @@ class IdentityServiceTest : BaseServiceTest() { assertTrue(result.isFailure) } + @Test + fun `verifyEmailToken should return Valid when response is success`() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val result = identityService.verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + token = EMAIL_TOKEN, + email = EMAIL, + ), + ) + assertTrue(result.isSuccess) + } + + @Suppress("MaxLineLength") + @Test + fun `verifyEmailToken should return TokenExpired when response is expired error`() = runTest { + val json = """ + { + "message": "Expired link. Please restart registration or try logging in. You may already have an account" + } + """.trimIndent() + val response = MockResponse().setResponseCode(400).setBody(json) + server.enqueue(response) + val result = identityService.verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + token = EMAIL_TOKEN, + email = EMAIL, + ), + ) + assertTrue(result.isSuccess) + assertEquals( + VerifyEmailTokenResponseJson.TokenExpired, + result.getOrThrow(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `verifyEmailToken should return Invalid when response message is non expired error`() = runTest { + val messageWithOutExpired = "message without expir... whoops" + val json = """ + { + "message": "$messageWithOutExpired" + } + """.trimIndent() + val response = MockResponse().setResponseCode(400).setBody(json) + server.enqueue(response) + val result = identityService.verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + token = EMAIL_TOKEN, + email = EMAIL, + ), + ) + assertTrue(result.isSuccess) + assertEquals( + VerifyEmailTokenResponseJson.Invalid(messageWithOutExpired), + result.getOrThrow(), + ) + } + + @Test + fun `verifyEmailToken should return an error when response is an un-handled error`() = runTest { + server.enqueue(MockResponse().setResponseCode(500)) + val result = identityService.verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + email = EMAIL, + token = EMAIL_TOKEN, + ), + ) + assertTrue(result.isFailure) + } + companion object { private const val UNIQUE_APP_ID = "testUniqueAppId" private const val REFRESH_TOKEN = "refreshToken" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 35c1d4198..c79a7285e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -42,6 +42,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserD import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService @@ -61,6 +63,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult @@ -6012,6 +6015,90 @@ class AuthRepositoryTest { ) } + @Test + fun `validateEmailToken should return success result when service returns success`() = runTest { + coEvery { + identityService + .verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + email = EMAIL, + token = EMAIL_VERIFICATION_TOKEN, + ), + ) + } returns VerifyEmailTokenResponseJson.Valid.asSuccess() + + val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN) + + assertEquals( + EmailTokenResult.Success, + emailTokenResult, + ) + } + + @Test + fun `validateEmailToken should return expired result when service returns TokenExpired`() = + runTest { + coEvery { + identityService + .verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + email = EMAIL, + token = EMAIL_VERIFICATION_TOKEN, + ), + ) + } returns VerifyEmailTokenResponseJson.TokenExpired.asSuccess() + + val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN) + + assertEquals( + EmailTokenResult.Expired, + emailTokenResult, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateEmailToken should return error result when service returns error without expired message`() = + runTest { + val errorMessage = "I haven't heard of second breakfast." + coEvery { + identityService + .verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + email = EMAIL, + token = EMAIL_VERIFICATION_TOKEN, + ), + ) + } returns VerifyEmailTokenResponseJson.Invalid(message = errorMessage).asSuccess() + + val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN) + + assertEquals( + EmailTokenResult.Error(message = errorMessage), + emailTokenResult, + ) + } + + @Test + fun `validateEmailToken should return error result when service returns failure`() = runTest { + coEvery { + identityService + .verifyEmailRegistrationToken( + body = VerifyEmailTokenRequestJson( + email = EMAIL, + token = EMAIL_VERIFICATION_TOKEN, + ), + ) + } returns Exception().asFailure() + + val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN) + + assertEquals( + EmailTokenResult.Error(message = null), + emailTokenResult, + ) + } + companion object { private const val UNIQUE_APP_ID = "testUniqueAppId" private const val NAME = "Example Name" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt index 31c71858e..02d2bd91d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt @@ -51,7 +51,7 @@ class SpecialCircumstanceManagerTest { assertNull(awaitItem()) val preLoginSpecialCircumstance = - mockk<SpecialCircumstance.PreLogin.CompleteRegistration>() + mockk<SpecialCircumstance.RegistrationEvent.CompleteRegistration>() specialCircumstanceManager.specialCircumstance = preLoginSpecialCircumstance assertEquals(preLoginSpecialCircumstance, awaitItem()) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt index 708ecf4b3..82748359c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt @@ -20,7 +20,7 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.model.FlagKey -import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance.PreLogin +import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance.RegistrationEvent import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository @@ -93,7 +93,8 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() { every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableFeatureFlagFlow } private val mutableGeneratorResultFlow = bufferedMutableSharedFlow<GeneratorResult>() - private val mockCompleteRegistrationCircumstance = mockk<PreLogin.CompleteRegistration>() + private val mockCompleteRegistrationCircumstance = + mockk<RegistrationEvent.CompleteRegistration>() private val generatorRepository = mockk<GeneratorRepository>(relaxed = true) { every { generatorResultFlow } returns mutableGeneratorResultFlow } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt index 5d136fa9e..e76a6f6ec 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt @@ -25,7 +25,7 @@ class ExpiredRegistrationLinkScreenTest : BaseComposeTest() { @Before fun setUp() { - composeTestRule.setContent { + setContentWithBackDispatcher { ExpiredRegistrationLinkScreen( onNavigateBack = { onNavigateBackCalled = true }, onNavigateToLogin = { onNavigateToLoginCalled = true }, @@ -35,6 +35,12 @@ class ExpiredRegistrationLinkScreenTest : BaseComposeTest() { } } + @Test + fun `System back event invokes CloseClicked action`() { + backDispatcher?.onBackPressed() + verify { viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) } + } + @Test fun `CloseClicked sends NavigateBack action`() { composeTestRule diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt index 16e10bffc..b40e82f48 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt @@ -1,37 +1,51 @@ package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class ExpiredRegistrationLinkViewModelTest : BaseViewModelTest() { + private val authRepository = mockk<AuthRepository>(relaxed = true) + @Test - fun `CloseClicked sends NavigateBack event`() = runTest { - val viewModel = ExpiredRegistrationLinkViewModel() + fun `CloseClicked sends NavigateBack event and resets pending account addition`() = runTest { + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) assertEquals(ExpiredRegistrationLinkEvent.NavigateBack, awaitItem()) } + verify { authRepository.hasPendingAccountAddition = true } } + @Suppress("MaxLineLength") @Test - fun `RestartRegistrationClicked sends NavigateToStartRegistration event`() = runTest { - val viewModel = ExpiredRegistrationLinkViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(ExpiredRegistrationLinkAction.RestartRegistrationClicked) - assertEquals(ExpiredRegistrationLinkEvent.NavigateToStartRegistration, awaitItem()) + fun `RestartRegistrationClicked sends NavigateToStartRegistration event and resets pending account addition`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ExpiredRegistrationLinkAction.RestartRegistrationClicked) + assertEquals(ExpiredRegistrationLinkEvent.NavigateToStartRegistration, awaitItem()) + } + verify { authRepository.hasPendingAccountAddition = true } } - } @Test - fun `GoToLoginClicked sends NavigateToLogin event`() = runTest { - val viewModel = ExpiredRegistrationLinkViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked) - assertEquals(ExpiredRegistrationLinkEvent.NavigateToLogin, awaitItem()) + fun `GoToLoginClicked sends NavigateToLogin event and resets pending account addition`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked) + assertEquals(ExpiredRegistrationLinkEvent.NavigateToLogin, awaitItem()) + } + verify { authRepository.hasPendingAccountAddition = true } } - } + + private fun createViewModel(): ExpiredRegistrationLinkViewModel = + ExpiredRegistrationLinkViewModel(authRepository = authRepository) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 035f773a7..9081b1774 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -663,7 +663,7 @@ class RootNavViewModelTest : BaseViewModelTest() { every { authRepository.hasPendingAccountAddition } returns false specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.PreLogin.CompleteRegistration( + SpecialCircumstance.RegistrationEvent.CompleteRegistration( CompleteRegistrationData( email = "example@email.com", verificationToken = "token", @@ -690,7 +690,7 @@ class RootNavViewModelTest : BaseViewModelTest() { every { authRepository.hasPendingAccountAddition } returns true specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.PreLogin.CompleteRegistration( + SpecialCircumstance.RegistrationEvent.CompleteRegistration( CompleteRegistrationData( email = "example@email.com", verificationToken = "token", @@ -740,7 +740,7 @@ class RootNavViewModelTest : BaseViewModelTest() { every { authRepository.hasPendingAccountAddition } returns true specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.PreLogin.CompleteRegistration( + SpecialCircumstance.RegistrationEvent.CompleteRegistration( CompleteRegistrationData( email = "example@email.com", verificationToken = "token", @@ -784,6 +784,97 @@ class RootNavViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `when there are no accounts but there is a ExpiredRegistrationLink special circumstance the nav state should be ExpiredRegistrationLink`() { + every { authRepository.hasPendingAccountAddition } returns false + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink + mutableUserStateFlow.tryEmit(null) + val viewModel = createViewModel() + assertEquals( + RootNavState.ExpiredRegistrationLink, + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `when the active user has an unlocked vault but there is a ExpiredRegistrationLink special circumstance the nav state should be ExpiredRegistrationLink`() { + every { authRepository.hasPendingAccountAddition } returns true + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarHexColor", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.ExpiredRegistrationLink, + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `when the active user has a locked vault but there is a ExpiredRegistrationLink special circumstance the nav state should be ExpiredRegistrationLink`() { + every { authRepository.hasPendingAccountAddition } returns true + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = false, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.ExpiredRegistrationLink, + viewModel.stateFlow.value, + ) + } + @Test fun `when the active user has a locked vault the nav state should be VaultLocked`() { mutableUserStateFlow.tryEmit(