mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
[PM-10884] Catch ProviderException when generating a secure key (#3733)
This commit is contained in:
parent
7dbfcfdea2
commit
5a7dc198dd
10 changed files with 340 additions and 47 deletions
|
@ -9,9 +9,9 @@ interface BiometricsEncryptionManager {
|
||||||
/**
|
/**
|
||||||
* Creates a [Cipher] built from a keystore.
|
* Creates a [Cipher] built from a keystore.
|
||||||
*/
|
*/
|
||||||
fun createCipher(
|
fun createCipherOrNull(
|
||||||
userId: String,
|
userId: String,
|
||||||
): Cipher
|
): Cipher?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the [Cipher] built from a keystore, or creates one if it doesn't already exist.
|
* Gets the [Cipher] built from a keystore, or creates one if it doesn't already exist.
|
||||||
|
|
|
@ -6,13 +6,20 @@ import android.security.keystore.KeyProperties
|
||||||
import com.x8bit.bitwarden.BuildConfig
|
import com.x8bit.bitwarden.BuildConfig
|
||||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||||
|
import java.io.IOException
|
||||||
import java.security.InvalidAlgorithmParameterException
|
import java.security.InvalidAlgorithmParameterException
|
||||||
import java.security.InvalidKeyException
|
import java.security.InvalidKeyException
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
|
import java.security.KeyStoreException
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
|
import java.security.NoSuchProviderException
|
||||||
|
import java.security.ProviderException
|
||||||
import java.security.UnrecoverableKeyException
|
import java.security.UnrecoverableKeyException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.NoSuchPaddingException
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -39,9 +46,20 @@ class BiometricsEncryptionManagerImpl(
|
||||||
.setInvalidatedByBiometricEnrollment(true)
|
.setInvalidatedByBiometricEnrollment(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun createCipher(userId: String): Cipher {
|
override fun createCipherOrNull(userId: String): Cipher? {
|
||||||
val secretKey: SecretKey = generateKey()
|
val secretKey: SecretKey = generateKeyOrNull()
|
||||||
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
?: run {
|
||||||
|
// user removed all biometrics from the device
|
||||||
|
settingsDiskSource.systemBiometricIntegritySource = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val cipher = try {
|
||||||
|
Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
return null
|
||||||
|
} catch (e: NoSuchPaddingException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
// This should never fail to initialize / return false because the cipher is newly generated
|
// This should never fail to initialize / return false because the cipher is newly generated
|
||||||
initializeCipher(
|
initializeCipher(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
@ -52,13 +70,14 @@ class BiometricsEncryptionManagerImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getOrCreateCipher(userId: String): Cipher? {
|
override fun getOrCreateCipher(userId: String): Cipher? {
|
||||||
val secretKey = try {
|
val secretKey = getSecretKeyOrNull()
|
||||||
getSecretKey() ?: generateKey()
|
?: generateKeyOrNull()
|
||||||
} catch (e: InvalidAlgorithmParameterException) {
|
?: run {
|
||||||
// user removed all biometrics from the device
|
// user removed all biometrics from the device
|
||||||
settingsDiskSource.systemBiometricIntegritySource = null
|
settingsDiskSource.systemBiometricIntegritySource = null
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||||
val isCipherInitialized = initializeCipher(
|
val isCipherInitialized = initializeCipher(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
@ -88,24 +107,67 @@ class BiometricsEncryptionManagerImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a [SecretKey] from which the [Cipher] will be generated.
|
* Generates a [SecretKey] from which the [Cipher] will be generated, or `null` if a key cannot
|
||||||
|
* be generated.
|
||||||
*/
|
*/
|
||||||
private fun generateKey(): SecretKey {
|
private fun generateKeyOrNull(): SecretKey? {
|
||||||
val keyGen = KeyGenerator.getInstance(
|
val keyGen = try {
|
||||||
|
KeyGenerator.getInstance(
|
||||||
KeyProperties.KEY_ALGORITHM_AES,
|
KeyProperties.KEY_ALGORITHM_AES,
|
||||||
ENCRYPTION_KEYSTORE_NAME,
|
ENCRYPTION_KEYSTORE_NAME,
|
||||||
)
|
)
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
return null
|
||||||
|
} catch (e: NoSuchProviderException) {
|
||||||
|
return null
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
keyGen.init(keyGenParameterSpec)
|
keyGen.init(keyGenParameterSpec)
|
||||||
keyGen.generateKey()
|
keyGen.generateKey()
|
||||||
return requireNotNull(getSecretKey())
|
} catch (e: InvalidAlgorithmParameterException) {
|
||||||
|
return null
|
||||||
|
} catch (e: ProviderException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSecretKeyOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the [SecretKey] stored in the keystore, or null if there isn't one.
|
* Returns the [SecretKey] stored in the keystore, or null if there isn't one.
|
||||||
*/
|
*/
|
||||||
private fun getSecretKey(): SecretKey? {
|
private fun getSecretKeyOrNull(): SecretKey? {
|
||||||
|
try {
|
||||||
keystore.load(null)
|
keystore.load(null)
|
||||||
return keystore.getKey(ENCRYPTION_KEY_NAME, null) as? SecretKey
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// keystore could not be loaded because [param] is unrecognized.
|
||||||
|
return null
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// keystore data format is invalid or the password is incorrect.
|
||||||
|
return null
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
// keystore integrity could not be checked due to missing algorithm.
|
||||||
|
return null
|
||||||
|
} catch (e: CertificateException) {
|
||||||
|
// keystore certificates could not be loaded
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
keystore.getKey(ENCRYPTION_KEY_NAME, null) as? SecretKey
|
||||||
|
} catch (e: KeyStoreException) {
|
||||||
|
// keystore was not loaded
|
||||||
|
null
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
// keystore algorithm cannot be found
|
||||||
|
null
|
||||||
|
} catch (e: UnrecoverableKeyException) {
|
||||||
|
// key could not be recovered
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -137,7 +199,7 @@ class BiometricsEncryptionManagerImpl(
|
||||||
* Validates the keystore key and decrypts it using the user-provided [cipher].
|
* Validates the keystore key and decrypts it using the user-provided [cipher].
|
||||||
*/
|
*/
|
||||||
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
|
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
|
||||||
val secretKey = getSecretKey()
|
val secretKey = getSecretKeyOrNull()
|
||||||
return if (cipher != null && secretKey != null) {
|
return if (cipher != null && secretKey != null) {
|
||||||
initializeCipher(
|
initializeCipher(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
@ -165,12 +227,9 @@ class BiometricsEncryptionManagerImpl(
|
||||||
value = true,
|
value = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
// Ignore result so biometrics function on devices that are in a state where key generation
|
||||||
createCipher(userId)
|
// is not functioning
|
||||||
} catch (e: Exception) {
|
createCipherOrNull(userId)
|
||||||
// Catch silently to allow biometrics to function on devices that are in
|
|
||||||
// a state where key generation is not functioning
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,8 @@ import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
|
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
|
||||||
|
@ -86,7 +88,12 @@ fun SetupUnlockScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SetupUnlockScreenDialogs(dialogState = state.dialogState)
|
SetupUnlockScreenDialogs(
|
||||||
|
dialogState = state.dialogState,
|
||||||
|
onDismissRequest = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(SetupUnlockAction.DismissDialog) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
|
@ -292,12 +299,21 @@ private fun SetupUnlockHeaderLandscape(
|
||||||
@Composable
|
@Composable
|
||||||
private fun SetupUnlockScreenDialogs(
|
private fun SetupUnlockScreenDialogs(
|
||||||
dialogState: SetupUnlockState.DialogState?,
|
dialogState: SetupUnlockState.DialogState?,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (dialogState) {
|
when (dialogState) {
|
||||||
is SetupUnlockState.DialogState.Loading -> BitwardenLoadingDialog(
|
is SetupUnlockState.DialogState.Loading -> BitwardenLoadingDialog(
|
||||||
visibilityState = LoadingDialogState.Shown(text = dialogState.title),
|
visibilityState = LoadingDialogState.Shown(text = dialogState.title),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is SetupUnlockState.DialogState.Error -> BitwardenBasicDialog(
|
||||||
|
visibilityState = BasicDialogState.Shown(
|
||||||
|
title = dialogState.title,
|
||||||
|
message = dialogState.message,
|
||||||
|
),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
)
|
||||||
|
|
||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,7 @@ class SetupUnlockViewModel @Inject constructor(
|
||||||
SetupUnlockAction.ContinueClick -> handleContinueClick()
|
SetupUnlockAction.ContinueClick -> handleContinueClick()
|
||||||
SetupUnlockAction.EnableBiometricsClick -> handleEnableBiometricsClick()
|
SetupUnlockAction.EnableBiometricsClick -> handleEnableBiometricsClick()
|
||||||
SetupUnlockAction.SetUpLaterClick -> handleSetUpLaterClick()
|
SetupUnlockAction.SetUpLaterClick -> handleSetUpLaterClick()
|
||||||
|
SetupUnlockAction.DismissDialog -> handleDismissDialog()
|
||||||
is SetupUnlockAction.UnlockWithBiometricToggle -> {
|
is SetupUnlockAction.UnlockWithBiometricToggle -> {
|
||||||
handleUnlockWithBiometricToggle(action)
|
handleUnlockWithBiometricToggle(action)
|
||||||
}
|
}
|
||||||
|
@ -70,18 +71,38 @@ class SetupUnlockViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleEnableBiometricsClick() {
|
private fun handleEnableBiometricsClick() {
|
||||||
|
biometricsEncryptionManager
|
||||||
|
.createCipherOrNull(userId = state.userId)
|
||||||
|
?.let {
|
||||||
sendEvent(
|
sendEvent(
|
||||||
SetupUnlockEvent.ShowBiometricsPrompt(
|
SetupUnlockEvent.ShowBiometricsPrompt(
|
||||||
// Generate a new key in case the previous one was invalidated
|
// Generate a new key in case the previous one was invalidated
|
||||||
cipher = biometricsEncryptionManager.createCipher(userId = state.userId),
|
cipher = it,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
?: run {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialogState = SetupUnlockState.DialogState.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleSetUpLaterClick() {
|
private fun handleSetUpLaterClick() {
|
||||||
sendEvent(SetupUnlockEvent.NavigateToSetupAutofill)
|
sendEvent(SetupUnlockEvent.NavigateToSetupAutofill)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleDismissDialog() {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(dialogState = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleUnlockWithBiometricToggle(
|
private fun handleUnlockWithBiometricToggle(
|
||||||
action: SetupUnlockAction.UnlockWithBiometricToggle,
|
action: SetupUnlockAction.UnlockWithBiometricToggle,
|
||||||
) {
|
) {
|
||||||
|
@ -182,6 +203,15 @@ data class SetupUnlockState(
|
||||||
data class Loading(
|
data class Loading(
|
||||||
val title: Text,
|
val title: Text,
|
||||||
) : DialogState()
|
) : DialogState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an error dialog with a title, message, and an acknowledgement button.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class Error(
|
||||||
|
val title: Text,
|
||||||
|
val message: Text,
|
||||||
|
) : DialogState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -235,6 +265,11 @@ sealed class SetupUnlockAction {
|
||||||
*/
|
*/
|
||||||
data object SetUpLaterClick : SetupUnlockAction()
|
data object SetUpLaterClick : SetupUnlockAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user has dismissed the dialog.
|
||||||
|
*/
|
||||||
|
data object DismissDialog : SetupUnlockAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Models actions that can be sent by the view model itself.
|
* Models actions that can be sent by the view model itself.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -376,6 +376,14 @@ private fun AccountSecurityDialogs(
|
||||||
visibilityState = LoadingDialogState.Shown(text = dialogState.message),
|
visibilityState = LoadingDialogState.Shown(text = dialogState.message),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is AccountSecurityDialog.Error -> BitwardenBasicDialog(
|
||||||
|
visibilityState = BasicDialogState.Shown(
|
||||||
|
title = dialogState.title,
|
||||||
|
message = dialogState.message,
|
||||||
|
),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
)
|
||||||
|
|
||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,15 +158,27 @@ class AccountSecurityViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleEnableBiometricsClick() {
|
private fun handleEnableBiometricsClick() {
|
||||||
|
biometricsEncryptionManager
|
||||||
|
.createCipherOrNull(userId = state.userId)
|
||||||
|
?.let {
|
||||||
sendEvent(
|
sendEvent(
|
||||||
AccountSecurityEvent.ShowBiometricsPrompt(
|
AccountSecurityEvent.ShowBiometricsPrompt(
|
||||||
// Generate a new key in case the previous one was invalidated
|
// Generate a new key in case the previous one was invalidated
|
||||||
cipher = biometricsEncryptionManager.createCipher(
|
cipher = it,
|
||||||
userId = state.userId,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
?: run {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = AccountSecurityDialog.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleFingerPrintLearnMoreClick() {
|
private fun handleFingerPrintLearnMoreClick() {
|
||||||
sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase)
|
sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase)
|
||||||
|
@ -407,6 +419,15 @@ sealed class AccountSecurityDialog : Parcelable {
|
||||||
data class Loading(
|
data class Loading(
|
||||||
val message: Text,
|
val message: Text,
|
||||||
) : AccountSecurityDialog()
|
) : AccountSecurityDialog()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an error dialog with a title and message.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class Error(
|
||||||
|
val title: Text,
|
||||||
|
val message: Text,
|
||||||
|
) : AccountSecurityDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -586,6 +586,40 @@ class SetupUnlockScreenTest : BaseComposeTest() {
|
||||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||||
composeTestRule.assertNoDialogExists()
|
composeTestRule.assertNoDialogExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `Error Dialog should be displayed according to state and send DismissDialog action on click`() {
|
||||||
|
val title = "title"
|
||||||
|
val message = "message"
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialogState = SetupUnlockState.DialogState.Error(
|
||||||
|
title = title.asText(),
|
||||||
|
message = message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithText(text = title)
|
||||||
|
.filterToOne(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithText("Ok")
|
||||||
|
.filterToOne(hasAnyAncestor(isDialog()))
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
viewModel.trySendAction(SetupUnlockAction.DismissDialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val DEFAULT_USER_ID: String = "user_id"
|
private const val DEFAULT_USER_ID: String = "user_id"
|
||||||
|
|
|
@ -40,6 +40,7 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
|
||||||
every {
|
every {
|
||||||
isBiometricIntegrityValid(userId = DEFAULT_USER_ID, cipher = CIPHER)
|
isBiometricIntegrityValid(userId = DEFAULT_USER_ID, cipher = CIPHER)
|
||||||
} returns false
|
} returns false
|
||||||
|
every { createCipherOrNull(DEFAULT_USER_ID) } returns CIPHER
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -210,6 +211,56 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on DismissDialog should hide dialog`() {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(SetupUnlockAction.DismissDialog)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(dialogState = null),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EnableBiometricsClick action should create a new biometrics cipher and emit result`() =
|
||||||
|
runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(SetupUnlockAction.EnableBiometricsClick)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
biometricsEncryptionManager.getOrCreateCipher(DEFAULT_USER_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
assertEquals(
|
||||||
|
SetupUnlockEvent.ShowBiometricsPrompt(CIPHER),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
fun `EnableBiometricsClick actin should show error dialog when cipher is null`() {
|
||||||
|
every {
|
||||||
|
biometricsEncryptionManager.createCipherOrNull(DEFAULT_USER_ID)
|
||||||
|
} returns null
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(SetupUnlockAction.EnableBiometricsClick)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialogState = SetupUnlockState.DialogState.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createViewModel(
|
private fun createViewModel(
|
||||||
state: SetupUnlockState? = null,
|
state: SetupUnlockState? = null,
|
||||||
): SetupUnlockViewModel =
|
): SetupUnlockViewModel =
|
||||||
|
|
|
@ -1287,6 +1287,55 @@ class AccountSecurityScreenTest : BaseComposeTest() {
|
||||||
verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
|
verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Error dialog should be shown or hidden according to state`() {
|
||||||
|
val title = "title"
|
||||||
|
val message = "message"
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = AccountSecurityDialog.Error(
|
||||||
|
title = title.asText(),
|
||||||
|
message = message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(title)
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText(message)
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
mutableStateFlow.update { it.copy(dialog = null) }
|
||||||
|
|
||||||
|
composeTestRule.assertNoDialogExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Error dialog dismiss should send DismissDialog`() {
|
||||||
|
val title = "title"
|
||||||
|
val message = "message"
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = AccountSecurityDialog.Error(
|
||||||
|
title = title.asText(),
|
||||||
|
message = message.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onAllNodesWithText("Ok")
|
||||||
|
.filterToOne(hasAnyAncestor(isDialog()))
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `fingerprint phrase dialog should be shown or hidden according to the state`() {
|
fun `fingerprint phrase dialog should be shown or hidden according to the state`() {
|
||||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||||
|
|
|
@ -57,7 +57,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
|
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
|
||||||
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk {
|
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk {
|
||||||
every { createCipher(DEFAULT_USER_STATE.activeUserId) } returns CIPHER
|
every { createCipherOrNull(DEFAULT_USER_STATE.activeUserId) } returns CIPHER
|
||||||
}
|
}
|
||||||
private val policyManager: PolicyManager = mockk {
|
private val policyManager: PolicyManager = mockk {
|
||||||
every {
|
every {
|
||||||
|
@ -341,6 +341,26 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on EnableBiometricsClick should show Error dialog when cipher is null`() {
|
||||||
|
every {
|
||||||
|
biometricsEncryptionManager.createCipherOrNull(DEFAULT_USER_STATE.activeUserId)
|
||||||
|
} returns null
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialog = AccountSecurityDialog.Error(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() =
|
fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
Loading…
Reference in a new issue