From 880bdc882645975d259bbd383dfd7fb368590efe Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Tue, 16 Jan 2024 09:46:15 -0600 Subject: [PATCH] BIT-1321, BIT-1014: Implement Verify PIN screen (#635) --- .../auth/repository/AuthRepositoryImpl.kt | 16 ++ .../data/auth/repository/model/UserState.kt | 2 + .../auth/repository/model/VaultUnlockType.kt | 16 ++ .../util/UserStateJsonExtensions.kt | 3 + .../vault/repository/VaultRepositoryImpl.kt | 28 ++++ .../feature/vaultunlock/VaultUnlockScreen.kt | 17 ++- .../vaultunlock/VaultUnlockViewModel.kt | 39 +++-- .../util/VaultUnlockTypeExtensions.kt | 56 +++++++ .../AccountSecurityViewModel.kt | 3 - .../auth/repository/AuthRepositoryTest.kt | 41 +++++- .../util/UserStateJsonExtensionsTest.kt | 5 + .../vault/repository/VaultRepositoryTest.kt | 79 +++++++++- .../vaultunlock/VaultUnlockScreenTest.kt | 89 ++++++++++- .../vaultunlock/VaultUnlockViewModelTest.kt | 138 ++++++++++++++++-- .../util/VaultUnlockTypeExtensionsTest.kt | 80 ++++++++++ .../AccountSecurityViewModelTest.kt | 106 ++++++-------- 16 files changed, 612 insertions(+), 106 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VaultUnlockType.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt 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 cb11672b71..bca7238284 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 @@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.toUserState @@ -113,6 +114,7 @@ class AuthRepositoryImpl constructor( vaultState = vaultState, userOrganizationsList = userOrganizationsList, specialCircumstance = specialCircumstance, + vaultUnlockTypeProvider = ::getVaultUnlockType, ) } .stateIn( @@ -124,6 +126,7 @@ class AuthRepositoryImpl constructor( vaultState = vaultRepository.vaultStateFlow.value, userOrganizationsList = authDiskSource.userOrganizationsList, specialCircumstance = mutableSpecialCircumstanceStateFlow.value, + vaultUnlockTypeProvider = ::getVaultUnlockType, ), ) @@ -401,4 +404,17 @@ class AuthRepositoryImpl constructor( }, ) } + + private fun getVaultUnlockType( + userId: String, + ): VaultUnlockType = + when { + authDiskSource.getPinProtectedUserKey(userId = userId) != null -> { + VaultUnlockType.PIN + } + + else -> { + VaultUnlockType.MASTER_PASSWORD + } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt index 19a1d8dc2b..906006f65c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt @@ -46,6 +46,7 @@ data class UserState( * authentication to view their vault. * @property isVaultUnlocked Whether or not the user's vault is currently unlocked. * @property organizations List of [Organization]s the user is associated with, if any. + * @property vaultUnlockType The mechanism by which the user's vault may be unlocked. */ data class Account( val userId: String, @@ -57,6 +58,7 @@ data class UserState( val isLoggedIn: Boolean, val isVaultUnlocked: Boolean, val organizations: List, + val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VaultUnlockType.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VaultUnlockType.kt new file mode 100644 index 0000000000..256554a0b5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VaultUnlockType.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * The mechanism by which the user's vault may be unlocked. + */ +enum class VaultUnlockType { + /** + * The vault must be unlocked using a master password. + */ + MASTER_PASSWORD, + + /** + * The vault must be unlocked using a PIN. + */ + PIN, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index 31e13ccd7d..7fe49efe3a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository.util import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.repository.model.VaultState @@ -47,6 +48,7 @@ fun UserStateJson.toUserState( vaultState: VaultState, userOrganizationsList: List, specialCircumstance: UserState.SpecialCircumstance?, + vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType, ): UserState = UserState( activeUserId = this.activeUserId, @@ -72,6 +74,7 @@ fun UserStateJson.toUserState( .find { it.userId == userId } ?.organizations .orEmpty(), + vaultUnlockType = vaultUnlockTypeProvider(userId), ) }, specialCircumstance = specialCircumstance, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 43302b8622..66a7743e45 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -300,6 +300,7 @@ class VaultRepositoryImpl( .also { if (it is VaultUnlockResult.Success) { sync() + deriveTemporaryPinProtectedUserKeyIfNecessary(userId = userId) } } } @@ -517,6 +518,33 @@ class VaultRepositoryImpl( ) } + /** + * Checks if the given [userId] has an associated encrypted PIN key but not a pin-protected user + * key. This indicates a scenario in which a user has requested PIN unlocking but requires + * master-password unlocking on app restart. This function may then be called after such an + * unlock to derive a pin-protected user key and store it in memory for use for any subsequent + * unlocks during this current app session. + * + * If the user's vault has not yet been unlocked, this call will do nothing. + */ + private suspend fun deriveTemporaryPinProtectedUserKeyIfNecessary(userId: String) { + val encryptedPin = authDiskSource.getEncryptedPin(userId = userId) ?: return + val existingPinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId) + if (existingPinProtectedUserKey != null) return + vaultSdkSource + .derivePinProtectedUserKey( + userId = userId, + encryptedPin = encryptedPin, + ) + .onSuccess { pinProtectedUserKey -> + authDiskSource.storePinProtectedUserKey( + userId = userId, + pinProtectedUserKey = pinProtectedUserKey, + inMemoryOnly = true, + ) + } + } + private fun storeProfileData( syncResponse: SyncResponseJson, ) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index 779934119a..555665b399 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -30,6 +30,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenInputLabel +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenKeyboardType +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenMessage +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenTitle import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BasicDialogState @@ -116,7 +120,7 @@ fun VaultUnlockScreen( .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenTopAppBar( - title = stringResource(id = R.string.verify_master_password), + title = state.vaultUnlockType.unlockScreenTitle(), scrollBehavior = scrollBehavior, navigationIcon = null, actions = { @@ -145,18 +149,19 @@ fun VaultUnlockScreen( .verticalScroll(rememberScrollState()), ) { BitwardenPasswordField( - label = stringResource(id = R.string.master_password), - value = state.passwordInput, + label = state.vaultUnlockType.unlockScreenInputLabel(), + value = state.input, onValueChange = remember(viewModel) { - { viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(it)) } + { viewModel.trySendAction(VaultUnlockAction.InputChanged(it)) } }, + keyboardType = state.vaultUnlockType.unlockScreenKeyboardType, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), ) Spacer(modifier = Modifier.height(24.dp)) Text( - text = stringResource(id = R.string.vault_locked_master_password), + text = state.vaultUnlockType.unlockScreenMessage(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier @@ -182,7 +187,7 @@ fun VaultUnlockScreen( onClick = remember(viewModel) { { viewModel.trySendAction(VaultUnlockAction.UnlockClick) } }, - isEnabled = state.passwordInput.isNotEmpty(), + isEnabled = state.input.isNotEmpty(), modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index e1d8078c26..8def8d00d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -7,9 +7,11 @@ import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage 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 @@ -50,7 +52,8 @@ class VaultUnlockViewModel @Inject constructor( email = activeAccountSummary.email, dialog = null, environmentUrl = environmentRepo.environment.label, - passwordInput = "", + input = "", + vaultUnlockType = userState.activeAccount.vaultUnlockType, ) }, ) { @@ -79,7 +82,7 @@ class VaultUnlockViewModel @Inject constructor( VaultUnlockAction.AddAccountClick -> handleAddAccountClick() VaultUnlockAction.DismissDialog -> handleDismissDialog() VaultUnlockAction.ConfirmLogoutClick -> handleConfirmLogoutClick() - is VaultUnlockAction.PasswordInputChanged -> handlePasswordInputChanged(action) + is VaultUnlockAction.InputChanged -> handleInputChanged(action) is VaultUnlockAction.LockAccountClick -> handleLockAccountClick(action) is VaultUnlockAction.LogoutAccountClick -> handleLogoutAccountClick(action) is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action) @@ -106,9 +109,9 @@ class VaultUnlockViewModel @Inject constructor( authRepository.logout() } - private fun handlePasswordInputChanged(action: VaultUnlockAction.PasswordInputChanged) { + private fun handleInputChanged(action: VaultUnlockAction.InputChanged) { mutableStateFlow.update { - it.copy(passwordInput = action.passwordInput) + it.copy(input = action.input) } } @@ -127,9 +130,19 @@ class VaultUnlockViewModel @Inject constructor( private fun handleUnlockClick() { mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) } viewModelScope.launch { - val vaultUnlockResult = vaultRepo.unlockVaultWithMasterPasswordAndSync( - mutableStateFlow.value.passwordInput, - ) + val vaultUnlockResult = when (state.vaultUnlockType) { + VaultUnlockType.MASTER_PASSWORD -> { + vaultRepo.unlockVaultWithMasterPasswordAndSync( + mutableStateFlow.value.input, + ) + } + + VaultUnlockType.PIN -> { + vaultRepo.unlockVaultWithPinAndSync( + mutableStateFlow.value.input, + ) + } + } sendAction(VaultUnlockAction.Internal.ReceiveVaultUnlockResult(vaultUnlockResult)) } } @@ -142,7 +155,7 @@ class VaultUnlockViewModel @Inject constructor( mutableStateFlow.update { it.copy( dialog = VaultUnlockState.VaultUnlockDialog.Error( - R.string.invalid_master_password.asText(), + state.vaultUnlockType.unlockScreenErrorMessage, ), ) } @@ -185,6 +198,7 @@ class VaultUnlockViewModel @Inject constructor( avatarColorString = activeAccountSummary.avatarColorHex, accountSummaries = accountSummaries, email = activeAccountSummary.email, + vaultUnlockType = userState.activeAccount.vaultUnlockType, ) } } @@ -201,7 +215,8 @@ data class VaultUnlockState( val email: String, val environmentUrl: String, val dialog: VaultUnlockDialog?, - val passwordInput: String, + val input: String, + val vaultUnlockType: VaultUnlockType, ) : Parcelable { /** @@ -261,10 +276,10 @@ sealed class VaultUnlockAction { data object ConfirmLogoutClick : VaultUnlockAction() /** - * The user has modified the password input. + * The user has modified the input. */ - data class PasswordInputChanged( - val passwordInput: String, + data class InputChanged( + val input: String, ) : VaultUnlockAction() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt new file mode 100644 index 0000000000..e2620bb40b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt @@ -0,0 +1,56 @@ +package com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util + +import androidx.compose.ui.text.input.KeyboardType +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText + +/** + * A title to use on the Vault Unlock screen. + */ +val VaultUnlockType.unlockScreenTitle: Text + get() = when (this) { + VaultUnlockType.MASTER_PASSWORD -> R.string.verify_master_password + VaultUnlockType.PIN -> R.string.verify_pin + } + .asText() + +/** + * A descriptive message to use on the Vault Unlock screen. + */ +val VaultUnlockType.unlockScreenMessage: Text + get() = when (this) { + VaultUnlockType.MASTER_PASSWORD -> R.string.vault_locked_master_password + VaultUnlockType.PIN -> R.string.vault_locked_pin + } + .asText() + +/** + * The label for the main text input to use on the Vault Unlock screen. + */ +val VaultUnlockType.unlockScreenInputLabel: Text + get() = when (this) { + VaultUnlockType.MASTER_PASSWORD -> R.string.master_password + VaultUnlockType.PIN -> R.string.pin + } + .asText() + +/** + * The error message to use for a failed unlock on the Vault Unlock screen. + */ +val VaultUnlockType.unlockScreenErrorMessage: Text + get() = when (this) { + VaultUnlockType.MASTER_PASSWORD -> R.string.invalid_master_password + VaultUnlockType.PIN -> R.string.invalid_pin + } + .asText() + +/** + * The [KeyboardType] to use on the input on the Vault Unlock screen. + */ +val VaultUnlockType.unlockScreenKeyboardType: KeyboardType + get() = when (this) { + VaultUnlockType.MASTER_PASSWORD -> KeyboardType.Password + VaultUnlockType.PIN -> KeyboardType.Number + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index 0696b81b0a..769be2d146 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -191,12 +191,10 @@ class AccountSecurityViewModel @Inject constructor( it.copy(isUnlockWithPinEnabled = action.isUnlockWithPinEnabled) } - // TODO: Complete implementation (BIT-465) when (action) { AccountSecurityAction.UnlockWithPinToggle.PendingEnabled -> Unit AccountSecurityAction.UnlockWithPinToggle.Disabled -> { settingsRepository.clearUnlockPin() - sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText())) } is AccountSecurityAction.UnlockWithPinToggle.Enabled -> { @@ -205,7 +203,6 @@ class AccountSecurityViewModel @Inject constructor( shouldRequireMasterPasswordOnRestart = action.shouldRequireMasterPasswordOnRestart, ) - sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText())) } } } 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 71ccb46d5e..5c0658aecd 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 @@ -35,6 +35,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams @@ -201,7 +202,7 @@ class AuthRepositoryTest { } @Test - fun `userStateFlow should update according to changes in its underyling data sources`() { + fun `userStateFlow should update according to changes in its underlying data sources`() { fakeAuthDiskSource.userState = null assertEquals( null, @@ -215,16 +216,28 @@ class AuthRepositoryTest { vaultState = VAULT_STATE, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ), repository.userStateFlow.value, ) - fakeAuthDiskSource.userState = MULTI_USER_STATE + fakeAuthDiskSource.apply { + storePinProtectedUserKey( + userId = USER_ID_1, + pinProtectedUserKey = "pinProtectedUseKey", + ) + storePinProtectedUserKey( + userId = USER_ID_2, + pinProtectedUserKey = "pinProtectedUseKey", + ) + userState = MULTI_USER_STATE + } assertEquals( MULTI_USER_STATE.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.PIN }, ), repository.userStateFlow.value, ) @@ -239,19 +252,31 @@ class AuthRepositoryTest { vaultState = emptyVaultState, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.PIN }, ), repository.userStateFlow.value, ) - fakeAuthDiskSource.storeOrganizations( - userId = USER_ID_1, - organizations = ORGANIZATIONS, - ) + fakeAuthDiskSource.apply { + storePinProtectedUserKey( + userId = USER_ID_1, + pinProtectedUserKey = null, + ) + storePinProtectedUserKey( + userId = USER_ID_2, + pinProtectedUserKey = null, + ) + storeOrganizations( + userId = USER_ID_1, + organizations = ORGANIZATIONS, + ) + } assertEquals( MULTI_USER_STATE.toUserState( vaultState = emptyVaultState, userOrganizationsList = USER_ORGANIZATIONS, specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ), repository.userStateFlow.value, ) @@ -280,6 +305,7 @@ class AuthRepositoryTest { vaultState = VAULT_STATE, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) mutableVaultStateFlow.value = VAULT_STATE fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 @@ -1054,6 +1080,7 @@ class AuthRepositoryTest { vaultState = VAULT_STATE, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 assertEquals( @@ -1084,6 +1111,7 @@ class AuthRepositoryTest { vaultState = VAULT_STATE, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 assertEquals( @@ -1112,6 +1140,7 @@ class AuthRepositoryTest { vaultState = VAULT_STATE, userOrganizationsList = emptyList(), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) fakeAuthDiskSource.userState = MULTI_USER_STATE assertEquals( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 55f41e1e67..46424c2530 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.repository.model.VaultState import io.mockk.every @@ -112,6 +113,7 @@ class UserStateJsonExtensionsTest { name = "organizationName", ), ), + vaultUnlockType = VaultUnlockType.PIN, ), ), ), @@ -153,6 +155,7 @@ class UserStateJsonExtensionsTest { ), ), specialCircumstance = null, + vaultUnlockTypeProvider = { VaultUnlockType.PIN }, ), ) } @@ -179,6 +182,7 @@ class UserStateJsonExtensionsTest { name = "organizationName", ), ), + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ), ), specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition, @@ -221,6 +225,7 @@ class UserStateJsonExtensionsTest { ), ), specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition, + vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 8542c67177..ca2b9b97e1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -651,11 +651,25 @@ class VaultRepositoryTest { @Suppress("MaxLineLength") @Test - fun `unlockVaultWithMasterPasswordAndSync with VaultLockManager Success should unlock for the current user, sync, and return Success`() = + fun `unlockVaultWithMasterPasswordAndSync with VaultLockManager Success and no encrypted PIN should unlock for the current user, sync, and return Success`() = runTest { val userId = "mockId-1" val mockVaultUnlockResult = VaultUnlockResult.Success + coEvery { + vaultSdkSource.derivePinProtectedUserKey(any(), any()) + } returns "pinProtectedUserKey".asSuccess() prepareStateForUnlocking(unlockResult = mockVaultUnlockResult) + fakeAuthDiskSource.apply { + storeEncryptedPin( + userId = userId, + encryptedPin = null, + ) + storePinProtectedUserKey( + userId = userId, + pinProtectedUserKey = null, + isInMemoryOnly = true, + ) + } val result = vaultRepository.unlockVaultWithMasterPasswordAndSync( masterPassword = "mockPassword-1", @@ -680,6 +694,69 @@ class VaultRepositoryTest { organizationKeys = createMockOrganizationKeys(number = 1), ) } + coVerify(exactly = 0) { vaultSdkSource.derivePinProtectedUserKey(any(), any()) } + } + + @Suppress("MaxLineLength") + @Test + fun `unlockVaultWithMasterPasswordAndSync with VaultLockManager Success and a stored encrypted pin should unlock for the current user, sync, derive a new pin-protected key, and return Success`() = + runTest { + val userId = "mockId-1" + val encryptedPin = "encryptedPin" + val pinProtectedUserKey = "pinProtectedUserkey" + val mockVaultUnlockResult = VaultUnlockResult.Success + coEvery { + vaultSdkSource.derivePinProtectedUserKey( + userId = userId, + encryptedPin = encryptedPin, + ) + } returns pinProtectedUserKey.asSuccess() + prepareStateForUnlocking(unlockResult = mockVaultUnlockResult) + fakeAuthDiskSource.apply { + storeEncryptedPin( + userId = userId, + encryptedPin = encryptedPin, + ) + storePinProtectedUserKey( + userId = userId, + pinProtectedUserKey = null, + isInMemoryOnly = true, + ) + } + + val result = vaultRepository.unlockVaultWithMasterPasswordAndSync( + masterPassword = "mockPassword-1", + ) + + assertEquals( + mockVaultUnlockResult, + result, + ) + fakeAuthDiskSource.assertPinProtectedUserKey( + userId = userId, + pinProtectedUserKey = pinProtectedUserKey, + inMemoryOnly = true, + ) + coVerify { syncService.sync() } + coVerify { + vaultLockManager.unlockVault( + userId = userId, + kdf = MOCK_PROFILE.toSdkParams(), + email = "email", + privateKey = "mockPrivateKey-1", + initUserCryptoMethod = InitUserCryptoMethod.Password( + password = "mockPassword-1", + userKey = "mockKey-1", + ), + organizationKeys = createMockOrganizationKeys(number = 1), + ) + } + coEvery { + vaultSdkSource.derivePinProtectedUserKey( + userId = userId, + encryptedPin = encryptedPin, + ) + } } @Suppress("MaxLineLength") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index 6d31fd1499..629b481681 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary @@ -113,6 +114,81 @@ class VaultUnlockScreenTest : BaseComposeTest() { ) } + @Test + fun `title should change according to state`() { + mutableStateFlow.update { + it.copy(vaultUnlockType = VaultUnlockType.MASTER_PASSWORD) + } + + composeTestRule + .onNodeWithText("Verify master password") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Verify PIN") + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy(vaultUnlockType = VaultUnlockType.PIN) + } + + composeTestRule + .onNodeWithText("Verify master password") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Verify PIN") + .assertIsDisplayed() + } + + @Test + fun `message should change according to state`() { + mutableStateFlow.update { + it.copy(vaultUnlockType = VaultUnlockType.MASTER_PASSWORD) + } + + composeTestRule + .onNodeWithText("Your vault is locked. Verify your master password to continue.") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Your vault is locked. Verify your PIN code to continue.") + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy(vaultUnlockType = VaultUnlockType.PIN) + } + + composeTestRule + .onNodeWithText("Your vault is locked. Verify your master password to continue.") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Your vault is locked. Verify your PIN code to continue.") + .assertIsDisplayed() + } + + @Test + fun `input label should change according to state`() { + mutableStateFlow.update { + it.copy(vaultUnlockType = VaultUnlockType.MASTER_PASSWORD) + } + + composeTestRule + .onNodeWithText("Master password") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("PIN") + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy(vaultUnlockType = VaultUnlockType.PIN) + } + + composeTestRule + .onNodeWithText("Master password") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("PIN") + .assertIsDisplayed() + } + @Suppress("MaxLineLength") @Test fun `lock button click in the lock-or-logout dialog should send LockAccountClick action and close the dialog`() { @@ -235,15 +311,15 @@ class VaultUnlockScreenTest : BaseComposeTest() { } @Test - fun `password input state change should update unlock button enabled`() { + fun `input state change should update unlock button enabled`() { composeTestRule.onNodeWithText("Unlock").performScrollTo().assertIsNotEnabled() - mutableStateFlow.update { it.copy(passwordInput = "a") } + mutableStateFlow.update { it.copy(input = "a") } composeTestRule.onNodeWithText("Unlock").performScrollTo().assertIsEnabled() } @Test fun `unlock click should send UnlockClick action`() { - mutableStateFlow.update { it.copy(passwordInput = "abdc1234") } + mutableStateFlow.update { it.copy(input = "abdc1234") } composeTestRule .onNodeWithText("Unlock") .performScrollTo() @@ -252,14 +328,14 @@ class VaultUnlockScreenTest : BaseComposeTest() { } @Test - fun `master password change should send PasswordInputChanged action`() { + fun `input change should send InputChanged action`() { val input = "abcd1234" composeTestRule .onNodeWithText("Master password") .performScrollTo() .performTextInput(input) verify { - viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(input)) + viewModel.trySendAction(VaultUnlockAction.InputChanged(input)) } } } @@ -300,5 +376,6 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( email = "bit@bitwarden.com", environmentUrl = DEFAULT_ENVIRONMENT_URL, initials = "AU", - passwordInput = "", + input = "", + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index ebcdcc9e63..caa47a2102 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository @@ -52,7 +53,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when set`() { val state = DEFAULT_STATE.copy( - passwordInput = "pass", + input = "pass", ) val viewModel = createViewModel(state = state) assertEquals(state, viewModel.stateFlow.value) @@ -199,9 +200,9 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { fun `on PasswordInputChanged should update the password input state`() = runTest { val viewModel = createViewModel() val password = "abcd1234" - viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(passwordInput = password)) + viewModel.trySendAction(VaultUnlockAction.InputChanged(input = password)) assertEquals( - DEFAULT_STATE.copy(passwordInput = password), + DEFAULT_STATE.copy(input = password), viewModel.stateFlow.value, ) } @@ -247,9 +248,12 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } @Test - fun `on UnlockClick should display error dialog on AuthenticationError`() = runTest { + fun `on UnlockClick for password unlock should display error dialog on AuthenticationError`() { val password = "abcd1234" - val initialState = DEFAULT_STATE.copy(passwordInput = password) + val initialState = DEFAULT_STATE.copy( + input = password, + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, + ) val viewModel = createViewModel(state = initialState) coEvery { vaultRepository.unlockVaultWithMasterPasswordAndSync(password) @@ -270,9 +274,12 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } @Test - fun `on UnlockClick should display error dialog on GenericError`() = runTest { + fun `on UnlockClick for password unlock should display error dialog on GenericError`() { val password = "abcd1234" - val initialState = DEFAULT_STATE.copy(passwordInput = password) + val initialState = DEFAULT_STATE.copy( + input = password, + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, + ) val viewModel = createViewModel(state = initialState) coEvery { vaultRepository.unlockVaultWithMasterPasswordAndSync(password) @@ -293,9 +300,12 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } @Test - fun `on UnlockClick should display error dialog on InvalidStateError`() = runTest { + fun `on UnlockClick for password unlock should display error dialog on InvalidStateError`() { val password = "abcd1234" - val initialState = DEFAULT_STATE.copy(passwordInput = password) + val initialState = DEFAULT_STATE.copy( + input = password, + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, + ) val viewModel = createViewModel(state = initialState) coEvery { vaultRepository.unlockVaultWithMasterPasswordAndSync(password) @@ -316,9 +326,12 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } @Test - fun `on UnlockClick should display clear dialog on success`() = runTest { + fun `on UnlockClick for password unlock should clear dialog on success`() { val password = "abcd1234" - val initialState = DEFAULT_STATE.copy(passwordInput = password) + val initialState = DEFAULT_STATE.copy( + input = password, + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, + ) val viewModel = createViewModel(state = initialState) coEvery { vaultRepository.unlockVaultWithMasterPasswordAndSync(password) @@ -334,6 +347,106 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } } + @Test + fun `on UnlockClick for PIN unlock should display error dialog on AuthenticationError`() { + val pin = "1234" + val initialState = DEFAULT_STATE.copy( + input = pin, + vaultUnlockType = VaultUnlockType.PIN, + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithPinAndSync(pin) + } returns VaultUnlockResult.AuthenticationError + + viewModel.trySendAction(VaultUnlockAction.UnlockClick) + assertEquals( + initialState.copy( + dialog = VaultUnlockState.VaultUnlockDialog.Error( + R.string.invalid_pin.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithPinAndSync(pin) + } + } + + @Test + fun `on UnlockClick for PIN unlock should display error dialog on GenericError`() { + val pin = "1234" + val initialState = DEFAULT_STATE.copy( + input = pin, + vaultUnlockType = VaultUnlockType.PIN, + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithPinAndSync(pin) + } returns VaultUnlockResult.GenericError + + viewModel.trySendAction(VaultUnlockAction.UnlockClick) + assertEquals( + initialState.copy( + dialog = VaultUnlockState.VaultUnlockDialog.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithPinAndSync(pin) + } + } + + @Test + fun `on UnlockClick for PIN unlock should display error dialog on InvalidStateError`() { + val pin = "1234" + val initialState = DEFAULT_STATE.copy( + input = pin, + vaultUnlockType = VaultUnlockType.PIN, + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithPinAndSync(pin) + } returns VaultUnlockResult.InvalidStateError + + viewModel.trySendAction(VaultUnlockAction.UnlockClick) + assertEquals( + initialState.copy( + dialog = VaultUnlockState.VaultUnlockDialog.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithPinAndSync(pin) + } + } + + @Test + fun `on UnlockClick for PIN unlock should clear dialog on success`() { + val pin = "1234" + val initialState = DEFAULT_STATE.copy( + input = pin, + vaultUnlockType = VaultUnlockType.PIN, + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithPinAndSync(pin) + } returns VaultUnlockResult.Success + + viewModel.trySendAction(VaultUnlockAction.UnlockClick) + assertEquals( + initialState.copy(dialog = null), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithPinAndSync(pin) + } + } + private fun createViewModel( state: VaultUnlockState? = DEFAULT_STATE, environmentRepo: EnvironmentRepository = environmentRepository, @@ -364,7 +477,8 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( initials = "AU", dialog = null, environmentUrl = Environment.Us.label, - passwordInput = "", + input = "", + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) private val DEFAULT_USER_STATE = UserState( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt new file mode 100644 index 0000000000..794bc7bbb6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt @@ -0,0 +1,80 @@ +package com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util + +import androidx.compose.ui.text.input.KeyboardType +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.ui.platform.base.util.asText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VaultUnlockTypeExtensionsTest { + @Test + fun `unlockScreenTitle should return the correct title for each type`() { + mapOf( + VaultUnlockType.MASTER_PASSWORD to R.string.verify_master_password.asText(), + VaultUnlockType.PIN to R.string.verify_pin.asText(), + ) + .forEach { (type, expected) -> + assertEquals( + expected, + type.unlockScreenTitle, + ) + } + } + + @Test + fun `unlockScreenMessage should return the correct title for each type`() { + mapOf( + VaultUnlockType.MASTER_PASSWORD to R.string.vault_locked_master_password.asText(), + VaultUnlockType.PIN to R.string.vault_locked_pin.asText(), + ) + .forEach { (type, expected) -> + assertEquals( + expected, + type.unlockScreenMessage, + ) + } + } + + @Test + fun `unlockScreenInputLabel should return the correct title for each type`() { + mapOf( + VaultUnlockType.MASTER_PASSWORD to R.string.master_password.asText(), + VaultUnlockType.PIN to R.string.pin.asText(), + ) + .forEach { (type, expected) -> + assertEquals( + expected, + type.unlockScreenInputLabel, + ) + } + } + + @Test + fun `unlockScreenErrorMessage should return the correct title for each type`() { + mapOf( + VaultUnlockType.MASTER_PASSWORD to R.string.invalid_master_password.asText(), + VaultUnlockType.PIN to R.string.invalid_pin.asText(), + ) + .forEach { (type, expected) -> + assertEquals( + expected, + type.unlockScreenErrorMessage, + ) + } + } + + @Test + fun `unlockScreenKeyboardType should return the correct title for each type`() { + mapOf( + VaultUnlockType.MASTER_PASSWORD to KeyboardType.Password, + VaultUnlockType.PIN to KeyboardType.Number, + ) + .forEach { (type, expected) -> + assertEquals( + expected, + type.unlockScreenKeyboardType, + ) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 24f4ced5a4..f247e96373 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -227,33 +227,26 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on UnlockWithPinToggle Disabled should set pin unlock to false, clear the PIN in settings, and emit ShowToast`() = - runTest { - val initialState = DEFAULT_STATE.copy( - isUnlockWithPinEnabled = true, - ) - val settingsRepository: SettingsRepository = mockk() { - every { clearUnlockPin() } just runs - } - val viewModel = createViewModel( - initialState = initialState, - settingsRepository = settingsRepository, - ) - viewModel.eventFlow.test { - viewModel.trySendAction( - AccountSecurityAction.UnlockWithPinToggle.Disabled, - ) - assertEquals( - AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText()), - awaitItem(), - ) - } - assertEquals( - initialState.copy(isUnlockWithPinEnabled = false), - viewModel.stateFlow.value, - ) - verify { settingsRepository.clearUnlockPin() } + fun `on UnlockWithPinToggle Disabled should set pin unlock to false and clear the PIN in settings`() { + val initialState = DEFAULT_STATE.copy( + isUnlockWithPinEnabled = true, + ) + val settingsRepository: SettingsRepository = mockk() { + every { clearUnlockPin() } just runs } + val viewModel = createViewModel( + initialState = initialState, + settingsRepository = settingsRepository, + ) + viewModel.trySendAction( + AccountSecurityAction.UnlockWithPinToggle.Disabled, + ) + assertEquals( + initialState.copy(isUnlockWithPinEnabled = false), + viewModel.stateFlow.value, + ) + verify { settingsRepository.clearUnlockPin() } + } @Suppress("MaxLineLength") @Test @@ -273,41 +266,34 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on UnlockWithPinToggle Enabled should set pin unlock to true, set the PIN in settings, and emit ShowToast`() = - runTest { - val initialState = DEFAULT_STATE.copy( - isUnlockWithPinEnabled = false, - ) - val settingsRepository: SettingsRepository = mockk() { - every { storeUnlockPin(any(), any()) } just runs - } - val viewModel = createViewModel( - initialState = initialState, - settingsRepository = settingsRepository, - ) - viewModel.eventFlow.test { - viewModel.trySendAction( - AccountSecurityAction.UnlockWithPinToggle.Enabled( - pin = "1234", - shouldRequireMasterPasswordOnRestart = true, - ), - ) - assertEquals( - AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText()), - awaitItem(), - ) - } - assertEquals( - initialState.copy(isUnlockWithPinEnabled = true), - viewModel.stateFlow.value, - ) - verify { - settingsRepository.storeUnlockPin( - pin = "1234", - shouldRequireMasterPasswordOnRestart = true, - ) - } + fun `on UnlockWithPinToggle Enabled should set pin unlock to true and set the PIN in settings`() { + val initialState = DEFAULT_STATE.copy( + isUnlockWithPinEnabled = false, + ) + val settingsRepository: SettingsRepository = mockk() { + every { storeUnlockPin(any(), any()) } just runs } + val viewModel = createViewModel( + initialState = initialState, + settingsRepository = settingsRepository, + ) + viewModel.trySendAction( + AccountSecurityAction.UnlockWithPinToggle.Enabled( + pin = "1234", + shouldRequireMasterPasswordOnRestart = true, + ), + ) + assertEquals( + initialState.copy(isUnlockWithPinEnabled = true), + viewModel.stateFlow.value, + ) + verify { + settingsRepository.storeUnlockPin( + pin = "1234", + shouldRequireMasterPasswordOnRestart = true, + ) + } + } @Test fun `on LogoutClick should show confirm log out dialog`() = runTest {