BIT-1273: Validate master password (#814)

This commit is contained in:
Shannon Draeker 2024-01-27 14:28:36 -07:00 committed by Álison Fernandes
parent 78a256ae3f
commit 5ce45a8069
19 changed files with 415 additions and 21 deletions

View file

@ -177,6 +177,16 @@ interface AuthDiskSource {
*/
fun getOrganizationsFlow(userId: String): Flow<List<SyncResponseJson.Profile.Organization>?>
/**
* Gets the master password hash for the given [userId].
*/
fun getMasterPasswordHash(userId: String): String?
/**
* Stores the [passwordHash] for the given [userId].
*/
fun storeMasterPasswordHash(userId: String, passwordHash: String?)
/**
* Stores the organization data for the given [userId].
*/

View file

@ -30,6 +30,7 @@ private const val ENCRYPTED_PIN_KEY = "$BASE_KEY:protectedPin"
private const val ORGANIZATIONS_KEY = "$BASE_KEY:organizations"
private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys"
private const val TWO_FACTOR_TOKEN_KEY = "$BASE_KEY:twoFactorToken"
private const val MASTER_PASSWORD_HASH_KEY = "$BASE_KEY:keyHash"
/**
* Primary implementation of [AuthDiskSource].
@ -104,6 +105,7 @@ class AuthDiskSourceImpl(
storeOrganizationKeys(userId = userId, organizationKeys = null)
storeOrganizations(userId = userId, organizations = null)
storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
storeMasterPasswordHash(userId = userId, passwordHash = null)
}
override fun getLastActiveTimeMillis(userId: String): Long? =
@ -267,6 +269,13 @@ class AuthDiskSourceImpl(
getMutableOrganizationsFlow(userId = userId).tryEmit(organizations)
}
override fun getMasterPasswordHash(userId: String): String? =
getString(key = "${MASTER_PASSWORD_HASH_KEY}_$userId")
override fun storeMasterPasswordHash(userId: String, passwordHash: String?) {
putString(key = "${MASTER_PASSWORD_HASH_KEY}_$userId", value = passwordHash)
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()

View file

@ -359,6 +359,21 @@ class AuthRepositoryImpl(
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = it,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userStateJson.activeUserId,
passwordHash = passwordHash,
)
}
}
authDiskSource.userState = userStateJson

View file

@ -180,4 +180,9 @@ interface SettingsRepository {
* Clears any previously set unlock PIN for the current user.
*/
fun clearUnlockPin()
/**
* Validate the master password.
*/
suspend fun validatePassword(password: String): Boolean
}

View file

@ -406,6 +406,21 @@ class SettingsRepositoryImpl(
}
.launchIn(unconfinedScope)
}
override suspend fun validatePassword(password: String): Boolean =
activeUserId
?.let { userId ->
authDiskSource
.getMasterPasswordHash(userId)
?.let { passwordHash ->
vaultSdkSource.validatePassword(
userId = userId,
password = password,
passwordHash = passwordHash,
)
}
}
?: false
}
/**

View file

@ -283,4 +283,13 @@ interface VaultSdkSource {
totp: String,
time: DateTime,
): Result<TotpResponse>
/**
* Validates that the given password matches the password hash.
*/
suspend fun validatePassword(
userId: String,
password: String,
passwordHash: String,
): Boolean
}

View file

@ -295,6 +295,18 @@ class VaultSdkSourceImpl(
)
}
override suspend fun validatePassword(
userId: String,
password: String,
passwordHash: String,
): Boolean =
getClient(userId = userId)
.auth()
.validatePassword(
password = password,
passwordHash = passwordHash,
)
private fun getClient(
userId: String,
): Client = sdkClientManager.getOrCreateClient(userId = userId)

View file

@ -4,8 +4,10 @@ import android.os.SystemClock
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
@ -53,6 +55,7 @@ private const val MAXIMUM_INVALID_UNLOCK_ATTEMPTS = 5
@Suppress("TooManyFunctions", "LongParameterList")
class VaultLockManagerImpl(
private val authDiskSource: AuthDiskSource,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val settingsRepository: SettingsRepository,
private val appForegroundManager: AppForegroundManager,
@ -98,6 +101,7 @@ class VaultLockManagerImpl(
}
}
@Suppress("LongMethod")
override suspend fun unlockVault(
userId: String,
email: String,
@ -142,6 +146,24 @@ class VaultLockManagerImpl(
onSuccess = { initializeCryptoResult ->
initializeCryptoResult
.toVaultUnlockResult()
.also {
if (initUserCryptoMethod is InitUserCryptoMethod.Password) {
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = initUserCryptoMethod.password,
kdf = kdf,
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = passwordHash,
)
}
}
}
.also {
if (it is VaultUnlockResult.Success) {
clearInvalidUnlockCount(userId = userId)

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.manager.di
import android.content.Context
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
@ -38,6 +39,7 @@ object VaultManagerModule {
@Singleton
fun provideVaultLockManager(
authDiskSource: AuthDiskSource,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
settingsRepository: SettingsRepository,
appForegroundManager: AppForegroundManager,
@ -46,6 +48,7 @@ object VaultManagerModule {
): VaultLockManager =
VaultLockManagerImpl(
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
settingsRepository = settingsRepository,
appForegroundManager = appForegroundManager,

View file

@ -71,6 +71,9 @@ fun ExportVaultScreen(
}
var shouldShowConfirmationDialog by remember { mutableStateOf(false) }
var confirmExportVaultClicked = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) }
}
if (shouldShowConfirmationDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.export_vault_confirmation_title),
@ -85,8 +88,9 @@ fun ExportVaultScreen(
},
confirmButtonText = stringResource(id = R.string.export_vault),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) }
onConfirmClick = {
shouldShowConfirmationDialog = false
confirmExportVaultClicked()
},
onDismissClick = { shouldShowConfirmationDialog = false },
onDismissRequest = { shouldShowConfirmationDialog = false },

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
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
@ -11,6 +13,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -21,6 +24,7 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class ExportVaultViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<ExportVaultState, ExportVaultEvent, ExportVaultAction>(
initialState = savedStateHandle[KEY_STATE]
@ -44,6 +48,10 @@ class ExportVaultViewModel @Inject constructor(
ExportVaultAction.DialogDismiss -> handleDialogDismiss()
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> {
handleReceiveValidatePasswordResult(action)
}
}
}
@ -58,9 +66,31 @@ class ExportVaultViewModel @Inject constructor(
* Verify the master password after confirming exporting the vault.
*/
private fun handleConfirmExportVaultClicked() {
// TODO: BIT-1273
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(ExportVaultEvent.ShowToast("Coming soon to a PR near you!".asText()))
// Display an error alert if the user hasn't entered a password.
if (mutableStateFlow.value.passwordInput.isBlank()) {
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.validation_field_required.asText(
R.string.master_password.asText(),
),
),
)
}
return
}
// Otherwise, validate the password.
viewModelScope.launch {
sendAction(
ExportVaultAction.Internal.ReceiveValidatePasswordResult(
isPasswordValid = settingsRepository.validatePassword(
password = mutableStateFlow.value.passwordInput,
),
),
)
}
}
/**
@ -87,6 +117,28 @@ class ExportVaultViewModel @Inject constructor(
it.copy(passwordInput = action.input)
}
}
/**
* Show an alert or proceed to export the vault after validating the password.
*/
private fun handleReceiveValidatePasswordResult(
action: ExportVaultAction.Internal.ReceiveValidatePasswordResult,
) {
// Display an error dialog if the password is invalid.
if (!action.isPasswordValid) {
mutableStateFlow.update {
it.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.invalid_master_password.asText(),
),
)
}
} else {
// TODO: BIT-1274, BIT-1275, and BIT-1276
sendEvent(ExportVaultEvent.ShowToast("Not yet implemented".asText()))
}
}
}
/**
@ -165,4 +217,16 @@ sealed class ExportVaultAction {
* Indicates that the password input has changed.
*/
data class PasswordInputChanged(val input: String) : ExportVaultAction()
/**
* Models actions that the [ExportVaultViewModel] might send itself.
*/
sealed class Internal : ExportVaultAction() {
/**
* Indicates that a validate password result has been received.
*/
data class ReceiveValidatePasswordResult(
val isPasswordValid: Boolean,
) : Internal()
}
}

View file

@ -713,6 +713,45 @@ class AuthDiskSourceTest {
json.parseToJsonElement(requireNotNull(actual)),
)
}
@Test
fun `getMasterPasswordHash should pull from SharedPreferences`() {
val passwordHashBaseKey = "bwPreferencesStorage:keyHash"
val mockUserId = "mockUserId"
val mockPasswordHash = "mockPasswordHash"
fakeSharedPreferences
.edit {
putString(
"${passwordHashBaseKey}_$mockUserId",
mockPasswordHash,
)
}
val actual = authDiskSource.getMasterPasswordHash(userId = mockUserId)
assertEquals(
mockPasswordHash,
actual,
)
}
@Test
fun `storeMasterPasswordHash should update SharedPreferences`() {
val passwordHashBaseKey = "bwPreferencesStorage:keyHash"
val mockUserId = "mockUserId"
val mockPasswordHash = "mockPasswordHash"
authDiskSource.storeMasterPasswordHash(
userId = mockUserId,
passwordHash = mockPasswordHash,
)
val actual = fakeSharedPreferences
.getString(
"${passwordHashBaseKey}_$mockUserId",
null,
)
assertEquals(
mockPasswordHash,
actual,
)
}
}
private const val USER_STATE_JSON = """

View file

@ -32,6 +32,7 @@ class FakeAuthDiskSource : AuthDiskSource {
mutableMapOf<String, List<SyncResponseJson.Profile.Organization>?>()
private val storedOrganizationKeys = mutableMapOf<String, Map<String, String>?>()
private val storedBiometricKeys = mutableMapOf<String, String?>()
private val storedMasterPasswordHashes = mutableMapOf<String, String?>()
override var userState: UserStateJson? = null
set(value) {
@ -156,6 +157,13 @@ class FakeAuthDiskSource : AuthDiskSource {
storedBiometricKeys[userId] = biometricsKey
}
override fun getMasterPasswordHash(userId: String): String? =
storedMasterPasswordHashes[userId]
override fun storeMasterPasswordHash(userId: String, passwordHash: String?) {
storedMasterPasswordHashes[userId] = passwordHash
}
/**
* Assert that the given [userState] matches the currently tracked value.
*/
@ -230,6 +238,13 @@ class FakeAuthDiskSource : AuthDiskSource {
assertEquals(organizationKeys, storedOrganizationKeys[userId])
}
/**
* Assert that the [passwordHash] was stored successfully using the [userId].
*/
fun assertMasterPasswordHash(userId: String, passwordHash: String?) {
assertEquals(passwordHash, storedMasterPasswordHashes[userId])
}
/**
* Assert that the [organizations] were stored successfully using the [userId].
*/

View file

@ -129,6 +129,14 @@ class AuthRepositoryTest {
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
} returns Result.success(PASSWORD_HASH)
coEvery {
hashPassword(
email = EMAIL,
password = PASSWORD,
kdf = ACCOUNT_1.profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
} returns Result.success(PASSWORD_HASH)
coEvery {
makeRegisterKeys(
email = EMAIL,
@ -638,6 +646,10 @@ class AuthRepositoryTest {
userId = USER_ID_1,
userKey = "key",
)
fakeAuthDiskSource.assertMasterPasswordHash(
userId = USER_ID_1,
passwordHash = PASSWORD_HASH,
)
coVerify {
identityService.getToken(
email = EMAIL,
@ -2475,6 +2487,7 @@ class AuthRepositoryTest {
private const val EMAIL_2 = "test2@bitwarden.com"
private const val PASSWORD = "password"
private const val PASSWORD_HASH = "passwordHash"
private const val PASSWORD_HASH_LOCAL = "passwordHashLocal"
private const val ACCESS_TOKEN = "accessToken"
private const val ACCESS_TOKEN_2 = "accessToken2"
private const val REFRESH_TOKEN = "refreshToken"

View file

@ -800,6 +800,40 @@ class SettingsRepositoryTest {
assertEquals(false, fakeSettingsDiskSource.getScreenCaptureAllowed(userId))
}
}
@Test
fun `validatePassword returns the validate password result`() = runTest {
val userId = "userId"
val password = "password"
val passwordHash = "passwordHash"
fakeAuthDiskSource.userState = MOCK_USER_STATE
fakeAuthDiskSource.storeMasterPasswordHash(userId = userId, passwordHash = passwordHash)
coEvery {
vaultSdkSource.validatePassword(
userId = userId,
password = password,
passwordHash = passwordHash,
)
} returns true
val result = settingsRepository
.validatePassword(
password = password,
)
assertTrue(result)
}
@Test
fun `validatePassword returns false if there's no stored password hash`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val password = "password"
val result = settingsRepository
.validatePassword(
password = password,
)
assertFalse(result)
}
}
private val MOCK_USER_STATE =

View file

@ -20,6 +20,7 @@ import com.bitwarden.core.SendView
import com.bitwarden.core.TotpResponse
import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientAuth
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientPlatform
@ -38,10 +39,12 @@ import io.mockk.verify
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class VaultSdkSourceTest {
private val clientAuth = mockk<ClientAuth>()
private val clientCrypto = mockk<ClientCrypto>()
private val clientPlatform = mockk<ClientPlatform>()
private val clientPasswordHistory = mockk<ClientPasswordHistory>()
@ -49,6 +52,7 @@ class VaultSdkSourceTest {
every { passwordHistory() } returns clientPasswordHistory
}
private val client = mockk<Client>() {
every { auth() } returns clientAuth
every { vault() } returns clientVault
every { platform() } returns clientPlatform
every { crypto() } returns clientCrypto
@ -708,4 +712,24 @@ class VaultSdkSourceTest {
verify { sdkClientManager.getOrCreateClient(userId = userId) }
}
@Test
fun `validatePassword should call SDK and return the expected Boolean`() = runTest {
val userId = "userId"
val password = "password"
val passwordHash = "passwordHash"
coEvery {
clientAuth.validatePassword(
password = password,
passwordHash = passwordHash,
)
} returns true
val result = vaultSdkSource.validatePassword(
userId = userId,
password = password,
passwordHash = passwordHash,
)
assertTrue(result)
}
}

View file

@ -3,9 +3,11 @@ package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.crypto.HashPurpose
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
@ -42,6 +44,16 @@ import org.junit.jupiter.api.Test
class VaultLockManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val fakeAppForegroundManager = FakeAppForegroundManager()
private val authSdkSource: AuthSdkSource = mockk {
coEvery {
hashPassword(
email = MOCK_PROFILE.email,
password = "drowssap",
kdf = MOCK_PROFILE.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
} returns Result.success("hashedPassword")
}
private val vaultSdkSource: VaultSdkSource = mockk {
every { clearCrypto(userId = any()) } just runs
}
@ -62,6 +74,7 @@ class VaultLockManagerTest {
private val vaultLockManager: VaultLockManager = VaultLockManagerImpl(
authDiskSource = fakeAuthDiskSource,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
settingsRepository = settingsRepository,
appForegroundManager = fakeAppForegroundManager,
@ -748,6 +761,10 @@ class VaultLockManagerTest {
userId = userId,
userAutoUnlockKey = null,
)
fakeAuthDiskSource.assertMasterPasswordHash(
userId = userId,
passwordHash = "hashedPassword",
)
coVerify(exactly = 1) {
vaultSdkSource.initializeCrypto(
userId = userId,

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
class ExportVaultScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
@ -165,6 +166,12 @@ class ExportVaultScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithText("Loading...").isDisplayed()
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(ExportVaultEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `password input change should send PasswordInputChange action`() {
val input = "Test123"
@ -173,12 +180,10 @@ class ExportVaultScreenTest : BaseComposeTest() {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged("Test123"))
}
}
companion object {
private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
)
}
}
private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
)

View file

@ -2,13 +2,20 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
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.exportvault.model.ExportVaultFormat
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ExportVaultViewModelTest : BaseViewModelTest() {
private val settingsRepository: SettingsRepository = mockk()
private val savedStateHandle = SavedStateHandle()
@Test
@ -31,6 +38,79 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ConfirmExportVaultClicked correct password should emit ShowToast`() = runTest {
val password = "password"
coEvery {
settingsRepository.validatePassword(
password = password,
)
} returns true
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(password))
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
assertEquals(
ExportVaultEvent.ShowToast("Not yet implemented".asText()),
awaitItem(),
)
}
}
@Test
fun `ConfirmExportVaultClicked blank password should show an error`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
assertEquals(
DEFAULT_STATE.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.validation_field_required.asText(
R.string.master_password.asText(),
),
),
),
viewModel.stateFlow.value,
)
viewModel.trySendAction(ExportVaultAction.DialogDismiss)
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
}
}
@Test
fun `ConfirmExportVaultClicked invalid password should show an error`() = runTest {
val password = "password"
coEvery {
settingsRepository.validatePassword(
password = password,
)
} returns false
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(password))
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
assertEquals(
DEFAULT_STATE.copy(
dialogState = ExportVaultState.DialogState.Error(
title = null,
message = R.string.invalid_master_password.asText(),
),
passwordInput = password,
),
viewModel.stateFlow.value,
)
}
}
@Test
fun `ExportFormatOptionSelect should update the selected export format in the state`() =
runTest {
@ -66,14 +146,13 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
private fun createViewModel(): ExportVaultViewModel =
ExportVaultViewModel(
settingsRepository = settingsRepository,
savedStateHandle = savedStateHandle,
)
companion object {
private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
)
}
}
private val DEFAULT_STATE = ExportVaultState(
dialogState = null,
exportFormat = ExportVaultFormat.JSON,
passwordInput = "",
)