BIT-2109: Add file password strength indicator to vault export (#1196)

This commit is contained in:
Caleb Derosier 2024-04-01 10:01:47 -06:00 committed by Álison Fernandes
parent b2005f01c1
commit 07ddb71cfc
3 changed files with 202 additions and 3 deletions

View file

@ -5,7 +5,9 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
@ -21,6 +23,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.toExpo
import com.x8bit.bitwarden.ui.platform.util.fileExtension
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@ -49,6 +52,7 @@ class ExportVaultViewModel @Inject constructor(
?: ExportVaultState(
confirmFilePasswordInput = "",
dialogState = null,
email = requireNotNull(authRepository.userStateFlow.value?.activeAccount?.email),
exportData = null,
exportFormat = ExportVaultFormat.JSON,
filePasswordInput = "",
@ -59,6 +63,12 @@ class ExportVaultViewModel @Inject constructor(
.any(),
),
) {
/**
* Keeps track of async request to get password strength. Should be cancelled
* when user input changes.
*/
private var passwordStrengthJob: Job = Job().apply { complete() }
init {
// As state updates, write to saved state handle.
stateFlow
@ -88,6 +98,10 @@ class ExportVaultViewModel @Inject constructor(
handleReceivePrepareVaultDataResult(action)
}
is ExportVaultAction.Internal.ReceivePasswordStrengthResult -> {
handleReceivePasswordStrengthResult(action)
}
is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> {
handleExportDataFinishedSavingToDisk(action)
}
@ -182,6 +196,21 @@ class ExportVaultViewModel @Inject constructor(
mutableStateFlow.update {
it.copy(filePasswordInput = action.input)
}
// Update password strength
passwordStrengthJob.cancel()
if (action.input.isEmpty()) {
mutableStateFlow.update {
it.copy(passwordStrengthState = PasswordStrengthState.NONE)
}
} else {
passwordStrengthJob = viewModelScope.launch {
val result = authRepository.getPasswordStrength(
email = state.email,
password = action.input,
)
trySendAction(ExportVaultAction.Internal.ReceivePasswordStrengthResult(result))
}
}
}
/**
@ -266,6 +295,31 @@ class ExportVaultViewModel @Inject constructor(
}
}
private fun handleReceivePasswordStrengthResult(
action: ExportVaultAction.Internal.ReceivePasswordStrengthResult,
) {
when (val result = action.result) {
is PasswordStrengthResult.Success -> {
val updatedState = when (result.passwordStrength) {
PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
}
mutableStateFlow.update { oldState ->
oldState.copy(
passwordStrengthState = updatedState,
)
}
}
PasswordStrengthResult.Error -> {
// Leave UI the same
}
}
}
private fun handleExportDataFinishedSavingToDisk(
action: ExportVaultAction.Internal.SaveExportDataToUriResultReceive,
) {
@ -298,6 +352,7 @@ data class ExportVaultState(
val exportData: String? = null,
val confirmFilePasswordInput: String,
val dialogState: DialogState?,
val email: String,
val exportFormat: ExportVaultFormat,
val filePasswordInput: String,
val passwordInput: String,
@ -413,6 +468,13 @@ sealed class ExportVaultAction {
val result: ExportVaultDataResult,
) : Internal()
/**
* Indicates that the result for getting the password strength has been received.
*/
data class ReceivePasswordStrengthResult(
val result: PasswordStrengthResult,
) : Internal()
/**
* Indicates that a validate password result has been received.
*/

View file

@ -260,6 +260,7 @@ class ExportVaultScreenTest : BaseComposeTest() {
private val DEFAULT_STATE = ExportVaultState(
confirmFilePasswordInput = "",
dialogState = null,
email = "test@bitwarden.com",
exportFormat = ExportVaultFormat.JSON,
filePasswordInput = "",
passwordInput = "",

View file

@ -4,9 +4,13 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.manager.FileManager
@ -20,6 +24,8 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -28,7 +34,10 @@ import java.time.Instant
import java.time.ZoneOffset
class ExportVaultViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
private val policyManager: PolicyManager = mockk {
every {
@ -61,6 +70,7 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
awaitItem(),
)
}
verify { authRepository.userStateFlow }
}
@Test
@ -147,6 +157,11 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.value,
)
}
coVerify {
authRepository.validatePassword(
password = password,
)
}
}
@Test
@ -209,15 +224,31 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
@Test
fun `FilePasswordInputChanged should update the file password input in the state`() {
val password = "Test123"
coEvery {
authRepository.getPasswordStrength(
email = EMAIL_ADDRESS,
password = password,
)
} returns PasswordStrengthResult.Success(
passwordStrength = PasswordStrength.LEVEL_4,
)
val viewModel = createViewModel()
viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange("Test123"))
viewModel.trySendAction(ExportVaultAction.FilePasswordInputChange(password))
assertEquals(
DEFAULT_STATE.copy(
filePasswordInput = "Test123",
filePasswordInput = password,
passwordStrengthState = PasswordStrengthState.STRONG,
),
viewModel.stateFlow.value,
)
coVerify {
authRepository.getPasswordStrength(
email = EMAIL_ADDRESS,
password = password,
)
}
}
@Test
@ -277,6 +308,89 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ReceivePasswordStrengthResult should update password strength state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.NONE,
),
awaitItem(),
)
viewModel.trySendAction(
ExportVaultAction.Internal.ReceivePasswordStrengthResult(
PasswordStrengthResult.Success(
PasswordStrength.LEVEL_0,
),
),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
),
awaitItem(),
)
viewModel.trySendAction(
ExportVaultAction.Internal.ReceivePasswordStrengthResult(
PasswordStrengthResult.Success(
PasswordStrength.LEVEL_1,
),
),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_2,
),
awaitItem(),
)
viewModel.trySendAction(
ExportVaultAction.Internal.ReceivePasswordStrengthResult(
PasswordStrengthResult.Success(
PasswordStrength.LEVEL_2,
),
),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_3,
),
awaitItem(),
)
viewModel.trySendAction(
ExportVaultAction.Internal.ReceivePasswordStrengthResult(
PasswordStrengthResult.Success(
PasswordStrength.LEVEL_3,
),
),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.GOOD,
),
awaitItem(),
)
viewModel.trySendAction(
ExportVaultAction.Internal.ReceivePasswordStrengthResult(
PasswordStrengthResult.Success(
PasswordStrength.LEVEL_4,
),
),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.STRONG,
),
awaitItem(),
)
}
}
@Test
fun `ExportLocationReceive should update state to error if exportData is null`() {
val viewModel = createViewModel()
@ -364,9 +478,31 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
)
}
private const val EMAIL_ADDRESS = "active@bitwarden.com"
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = EMAIL_ADDRESS,
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
),
),
)
private val DEFAULT_STATE = ExportVaultState(
confirmFilePasswordInput = "",
dialogState = null,
email = EMAIL_ADDRESS,
exportFormat = ExportVaultFormat.JSON,
filePasswordInput = "",
passwordInput = "",