PM-11604 Network layer for checking email token, nav to UI if needed. (#3862)

This commit is contained in:
Dave Severns 2024-09-06 13:40:00 -04:00 committed by GitHub
parent ae349183e8
commit e468ec695b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 756 additions and 56 deletions

View file

@ -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)

View file

@ -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()
}

View file

@ -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>
}

View file

@ -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,
)

View file

@ -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()
}

View file

@ -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>
}

View file

@ -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
}

View file

@ -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 {
/**

View file

@ -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
}

View file

@ -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,

View file

@ -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()
}

View file

@ -26,7 +26,7 @@ class SpecialCircumstanceManagerImpl(
it?.activeAccount?.isLoggedIn == true
}
.onEach { _ ->
if (specialCircumstance is SpecialCircumstance.PreLogin) {
if (specialCircumstance is SpecialCircumstance.RegistrationEvent) {
specialCircumstance = null
}
}

View file

@ -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()
}
}

View file

@ -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
}
/**

View file

@ -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,
),
)
},

View file

@ -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
}
}
/**

View file

@ -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

View file

@ -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>

View file

@ -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)
},

View file

@ -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"

View file

@ -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"

View file

@ -51,7 +51,7 @@ class SpecialCircumstanceManagerTest {
assertNull(awaitItem())
val preLoginSpecialCircumstance =
mockk<SpecialCircumstance.PreLogin.CompleteRegistration>()
mockk<SpecialCircumstance.RegistrationEvent.CompleteRegistration>()
specialCircumstanceManager.specialCircumstance = preLoginSpecialCircumstance
assertEquals(preLoginSpecialCircumstance, awaitItem())

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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(