[PM-9407] Confirm overwrite existing passkey on item listing (#3540)

This commit is contained in:
Patrick Honkonen 2024-07-18 12:35:05 -04:00 committed by GitHub
parent d9f506dd8f
commit 815e779475
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 243 additions and 16 deletions

View file

@ -41,6 +41,7 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten
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.BitwardenOverwritePasskeyConfirmationDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
@ -172,6 +173,15 @@ fun VaultItemListingScreen(
)
}
},
onConfirmOverwriteExistingPasskey = remember(viewModel) {
{ cipherId ->
viewModel.trySendAction(
VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick(
cipherViewId = cipherId,
),
)
}
},
)
VaultItemListingScaffold(
@ -188,6 +198,7 @@ private fun VaultItemListingDialogs(
dialogState: VaultItemListingState.DialogState?,
onDismissRequest: () -> Unit,
onDismissFido2ErrorDialog: () -> Unit,
onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit,
) {
when (dialogState) {
is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog(
@ -210,6 +221,13 @@ private fun VaultItemListingDialogs(
onDismissRequest = onDismissFido2ErrorDialog,
)
is VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt -> {
BitwardenOverwritePasskeyConfirmationDialog(
onConfirmClick = { onConfirmOverwriteExistingPasskey(dialogState.cipherViewId) },
onDismissRequest = onDismissRequest,
)
}
null -> Unit
}
}

View file

@ -199,6 +199,9 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick()
is VaultItemListingsAction.RefreshClick -> handleRefreshClick()
is VaultItemListingsAction.RefreshPull -> handleRefreshPull()
is VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick -> {
handleConfirmOverwriteExistingPasskeyClick(action)
}
VaultItemListingsAction.UserVerificationLockOut -> {
handleUserVerificationLockOut()
@ -255,6 +258,18 @@ class VaultItemListingViewModel @Inject constructor(
vaultRepository.sync()
}
private fun handleConfirmOverwriteExistingPasskeyClick(
action: VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick,
) {
clearDialogState()
getCipherViewOrNull(action.cipherViewId)
?.let { registerFido2Credential(it) }
?: run {
showFido2ErrorDialog()
return
}
}
private fun handleUserVerificationLockOut() {
fido2CredentialManager.isUserVerified = false
showFido2ErrorDialog()
@ -385,6 +400,21 @@ class VaultItemListingViewModel @Inject constructor(
showFido2ErrorDialog()
return
}
if (cipherView.isActiveWithFido2Credentials) {
mutableStateFlow.update {
it.copy(
dialogState = VaultItemListingState
.DialogState
.OverwritePasskeyConfirmationPrompt(cipherViewId = action.id),
)
}
} else {
registerFido2Credential(cipherView)
}
}
private fun registerFido2Credential(cipherView: CipherView) {
val credentialRequest = state
.fido2CredentialRequest
?: run {
@ -1209,6 +1239,12 @@ data class VaultItemListingState(
data class Loading(
val message: Text,
) : DialogState()
/**
* Displays the overwrite passkey confirmation prompt to the user.
*/
@Parcelize
data class OverwritePasskeyConfirmationPrompt(val cipherViewId: String) : DialogState()
}
/**
@ -1687,6 +1723,13 @@ sealed class VaultItemListingsAction {
*/
data object UserVerificationNotSupported : VaultItemListingsAction()
/**
* The user has confirmed overwriting the existing cipher's passkey.
*/
data class ConfirmOverwriteExistingPasskeyClick(
val cipherViewId: String,
) : VaultItemListingsAction()
/**
* Models actions that the [VaultItemListingViewModel] itself might send.
*/

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.autofill.util
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@ -139,7 +140,10 @@ class CipherViewExtensionsTest {
@Test
fun `isActiveWithFido2Credentials should return true when type is login, deleted date is null, and fido2 credentials are not null`() {
assertTrue(
createMockCipherView(number = 1)
createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
.isActiveWithFido2Credentials,
)
}

View file

@ -46,10 +46,7 @@ fun createMockCipherView(
totp: String? = "mockTotp-$number",
folderId: String? = "mockId-$number",
clock: Clock = FIXED_CLOCK,
fido2Credentials: List<Fido2Credential>? = createMockSdkFido2CredentialList(
number = 1,
clock = clock,
),
fido2Credentials: List<Fido2Credential>? = null,
): CipherView =
CipherView(
id = "mockId-$number",

View file

@ -30,6 +30,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockUriView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@ -1338,7 +1339,10 @@ class SearchViewModelTest : BaseViewModelTest() {
autofillSelectionData = AUTOFILL_SELECTION_DATA,
shouldFinishWhenComplete = true,
)
val cipherView = createMockCipherView(number = 1)
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
val ciphers = listOf(cipherView)
val expectedViewState = SearchState.ViewState.Content(
displayItems = listOf(createMockDisplayItemForCipher(number = 1)),

View file

@ -554,10 +554,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
),
totpCode = "mockTotp-1",
canViewPassword = true,
fido2CredentialCreationDateTime = R.string.created_xy.asText(
"10/27/23",
"12:00 PM",
),
fido2CredentialCreationDateTime = null,
)
.copy(totp = "mockTotp-1"),
),

View file

@ -1665,6 +1665,40 @@ class VaultItemListingScreenTest : BaseComposeTest() {
)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverwritePasskeyConfirmationPrompt should display based on dialog state and send ConfirmOverwriteExistingPasskeyClick on Ok click`() {
val stateWithDialog = DEFAULT_STATE
.copy(
dialogState = VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt(
cipherViewId = "mockCipherViewId",
),
)
mutableStateFlow.value = stateWithDialog
composeTestRule
.onNodeWithText("Overwrite passkey?")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onNodeWithText("This item already contains a passkey. Are you sure you want to overwrite the current passkey?")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick(
cipherViewId = "mockCipherViewId",
),
)
}
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(

View file

@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
@ -294,7 +295,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
fun `ItemClick for vault item when autofill should post to the AutofillSelectionManager`() =
runTest {
setupMockUri()
val cipherView = createMockCipherView(number = 1)
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
coEvery {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
cipherViewList = listOf(cipherView),
@ -391,12 +395,58 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `ItemClick for vault item during FIDO 2 registration should show overwrite passkey confirmation when selected cipher has existing passkey`() {
runTest {
setupMockUri()
val cipherView = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = createMockFido2CredentialRequest(number = 1),
)
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(cipherView),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
every {
fido2CredentialManager.getPasskeyCreateOptionsOrNull(any())
} returns createMockPublicKeyCredentialCreationOptions(
number = 1,
userVerificationRequirement = UserVerificationRequirement.REQUIRED,
)
coEvery {
fido2CredentialManager.registerFido2Credential(
any(),
any(),
any(),
)
} returns Fido2RegisterCredentialResult.Success("mockResponse")
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(VaultItemListingsAction.ItemClick(cipherView.id.orEmpty()))
assertEquals(
VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt(
cipherViewId = cipherView.id!!,
),
viewModel.stateFlow.value.dialogState,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `ItemClick for vault item during FIDO 2 registration should show loading dialog, then request user verification when required`() =
runTest {
setupMockUri()
val cipherView = createMockCipherView(number = 1)
val cipherView = createMockCipherView(number = 1, fido2Credentials = null)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = createMockFido2CredentialRequest(number = 1),
)
@ -1168,8 +1218,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
runTest {
setupMockUri()
val cipherView1 = createMockCipherView(number = 1)
val cipherView2 = createMockCipherView(number = 2)
val cipherView1 = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
val cipherView2 = createMockCipherView(
number = 2,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
coEvery {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
@ -1239,8 +1295,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
runTest {
setupMockUri()
val cipherView1 = createMockCipherView(number = 1)
val cipherView2 = createMockCipherView(number = 2)
val cipherView1 = createMockCipherView(
number = 1,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
val cipherView2 = createMockCipherView(
number = 2,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
coEvery {
vaultRepository.getDecryptedFido2CredentialAutofillViews(
@ -2292,6 +2354,71 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `ConfirmOverwriteExistingPasskeyClick should check if user is verified`() =
runTest {
setupMockUri()
val cipherView = createMockCipherView(number = 1)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = createMockFido2CredentialRequest(number = 1),
)
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(cipherView),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
every {
fido2CredentialManager.getPasskeyCreateOptionsOrNull(any())
} returns createMockPublicKeyCredentialCreationOptions(
number = 1,
userVerificationRequirement = UserVerificationRequirement.REQUIRED,
)
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(
VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick(
cipherViewId = cipherView.id!!,
),
)
verify { fido2CredentialManager.isUserVerified }
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmOverwriteExistingPasskeyClick should display Fido2ErrorDialog when getSelectedCipher returns null`() = runTest {
setupMockUri()
val cipherView = createMockCipherView(number = 1)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = createMockFido2CredentialRequest(number = 1),
)
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(cipherView),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(
VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick(
cipherViewId = "invalidId",
),
)
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("CyclomaticComplexMethod")
private fun createSavedStateHandleWithVaultItemListingType(
vaultItemListingType: VaultItemListingType,

View file

@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -456,6 +457,7 @@ class VaultItemListingDataExtensionsTest {
isDeleted = false,
cipherType = CipherType.LOGIN,
folderId = "mockId-1",
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
)
.copy(reprompt = CipherRepromptType.PASSWORD),
createMockCipherView(
@ -463,6 +465,7 @@ class VaultItemListingDataExtensionsTest {
isDeleted = false,
cipherType = CipherType.CARD,
folderId = "mockId-1",
fido2Credentials = createMockSdkFido2CredentialList(number = 2),
),
)
val fido2CredentialAutofillViews = listOf(