mirror of
https://github.com/bitwarden/android.git
synced 2025-02-17 04:19:54 +03:00
BIT-1273: Validate master password (#814)
This commit is contained in:
parent
78a256ae3f
commit
5ce45a8069
19 changed files with 415 additions and 21 deletions
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = """
|
||||
|
|
|
@ -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].
|
||||
*/
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = "",
|
||||
)
|
||||
|
|
|
@ -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 = "",
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue