BIT-2046 Display passkey fields in Vault (#1143)

This commit is contained in:
Patrick Honkonen 2024-03-19 12:35:35 -04:00 committed by Álison Fernandes
parent f9edd70beb
commit be127f5d49
24 changed files with 582 additions and 128 deletions

View file

@ -711,6 +711,7 @@ data class SyncResponseJson(
* @property shouldAutofillOnPageLoad If autofill is used on page load (nullable).
* @property uri The URI (nullable).
* @property username The username (nullable).
* @property fido2Credentials A list of FIDO 2 credentials (nullable).
*/
@Serializable
data class Login(

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledTonalButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@ -81,6 +82,18 @@ fun LazyListScope.vaultAddEditLoginItems(
)
}
loginState.fido2CredentialCreationDateTime?.let { creationDateTime ->
item {
Spacer(modifier = Modifier.height(8.dp))
PasskeyField(
creationDateTime = creationDateTime,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
BitwardenListHeaderText(
@ -476,3 +489,18 @@ private fun PasswordRow(
)
}
}
@Composable
private fun PasskeyField(
creationDateTime: Text,
modifier: Modifier = Modifier,
) {
BitwardenTextField(
label = stringResource(id = R.string.passkey),
value = creationDateTime.invoke(),
onValueChange = { },
readOnly = true,
singleLine = true,
modifier = modifier,
)
}

View file

@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.util.Collections
import java.util.UUID
import javax.inject.Inject
@ -82,6 +83,7 @@ class VaultAddEditViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val resourceManager: ResourceManager,
private val clock: Clock,
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
@ -1164,6 +1166,7 @@ class VaultAddEditViewModel @Inject constructor(
isClone = isCloneMode,
isIndividualVaultDisabled = isIndividualVaultDisabled,
resourceManager = resourceManager,
clock = clock,
) ?: viewState)
.appendFolderAndOwnerData(
folderViewList = vaultData.folderViewList,
@ -1557,6 +1560,8 @@ data class VaultAddEditState(
* @property totp The current TOTP (if applicable).
* @property canViewPassword Indicates whether the current user can view and copy
* passwords associated with the login item.
* @property fido2CredentialCreationDateTime Date and time the FIDO 2 credential was
* created.
*/
@Parcelize
data class Login(
@ -1567,6 +1572,7 @@ data class VaultAddEditState(
val uriList: List<UriItem> = listOf(
UriItem(id = UUID.randomUUID().toString(), uri = "", match = null),
),
val fido2CredentialCreationDateTime: Text? = null,
) : ItemType() {
override val itemTypeOption: ItemTypeOption get() = ItemTypeOption.LOGIN
}

View file

@ -6,6 +6,7 @@ import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.Fido2Credential
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.FolderView
@ -14,6 +15,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
@ -23,8 +25,12 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCollection
import com.x8bit.bitwarden.ui.vault.model.VaultIdentityTitle
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType.Companion.fromId
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import java.time.Clock
import java.util.UUID
private const val PASSKEY_CREATION_DATE_PATTERN: String = "M/d/yy"
private const val PASSKEY_CREATION_TIME_PATTERN: String = "hh:mm a"
/**
* Transforms [CipherView] into [VaultAddEditState.ViewState].
*/
@ -32,6 +38,7 @@ fun CipherView.toViewState(
isClone: Boolean,
isIndividualVaultDisabled: Boolean,
resourceManager: ResourceManager,
clock: Clock,
): VaultAddEditState.ViewState =
VaultAddEditState.ViewState.Content(
type = when (type) {
@ -39,9 +46,13 @@ fun CipherView.toViewState(
VaultAddEditState.ViewState.Content.ItemType.Login(
username = login?.username.orEmpty(),
password = login?.password.orEmpty(),
uriList = login?.uris.toUriItems(),
totp = login?.totp,
canViewPassword = this.viewPassword,
uriList = login?.uris.toUriItems(),
fido2CredentialCreationDateTime = login
?.fido2Credentials
.getPrimaryFido2CredentialOrNull(isClone)
?.getCreationDateTime(clock),
)
}
@ -272,3 +283,24 @@ private fun List<LoginUriView>?.toUriItems(): List<UriItem> =
)
}
}
/**
* Retrieves the cipher's primary (first) FIDO2 credential, or null if there is no FIDO2 credential
* assigned.
*/
private fun List<Fido2Credential>?.getPrimaryFido2CredentialOrNull(
isClone: Boolean,
): Fido2Credential? {
if (isNullOrEmpty() || isClone) return null
return first()
}
/**
* Return the creation date and time of the primary FIDO2 credential, formatted as
* "M/d/yy, hh:mm a".
*/
private fun Fido2Credential.getCreationDateTime(clock: Clock) = R.string.created_xy.asText(
creationDate.toFormattedPattern(pattern = PASSKEY_CREATION_DATE_PATTERN, clock = clock),
creationDate.toFormattedPattern(pattern = PASSKEY_CREATION_TIME_PATTERN, clock = clock),
)

View file

@ -100,6 +100,18 @@ fun VaultItemLoginContent(
}
}
loginItemState.fido2CredentialCreationDateText?.let { creationDate ->
item {
Spacer(modifier = Modifier.height(8.dp))
Fido2CredentialField(
creationDate = creationDate.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
loginItemState.totpCodeItemData?.let { totpCodeItemData ->
item {
Spacer(modifier = Modifier.height(8.dp))
@ -247,6 +259,21 @@ fun VaultItemLoginContent(
}
}
@Composable
private fun Fido2CredentialField(
creationDate: String,
modifier: Modifier = Modifier,
) {
BitwardenTextField(
label = stringResource(id = R.string.passkey),
value = creationDate,
onValueChange = { },
readOnly = true,
singleLine = true,
modifier = modifier,
)
}
@Composable
private fun NotesField(
notes: String,

View file

@ -137,13 +137,20 @@ fun VaultItemScreen(
)
}
},
onConfirmDeleteClick = remember {
onConfirmDeleteClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemAction.Common.ConfirmDeleteClick,
)
}
},
onConfirmCloneWithoutFido2Credential = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick,
)
}
},
)
if (pendingRestoreCipher) {
@ -291,6 +298,7 @@ private fun VaultItemDialogs(
onDismissRequest: () -> Unit,
onConfirmDeleteClick: () -> Unit,
onSubmitMasterPassword: (masterPassword: String, action: PasswordRepromptAction) -> Unit,
onConfirmCloneWithoutFido2Credential: () -> Unit,
) {
when (dialog) {
is VaultItemState.DialogState.Generic -> BitwardenBasicDialog(
@ -324,6 +332,18 @@ private fun VaultItemDialogs(
)
}
is VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt -> {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.passkey_will_not_be_copied),
message = dialog.message.invoke(),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.no),
onConfirmClick = onConfirmCloneWithoutFido2Credential,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
}
null -> Unit
}
}

View file

@ -151,6 +151,9 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
is VaultItemAction.Common.ConfirmRestoreClick -> handleConfirmRestoreClick()
is VaultItemAction.Common.DeleteClick -> handleDeleteClick()
is VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick -> {
handleConfirmCloneClick()
}
}
}
@ -373,9 +376,19 @@ class VaultItemViewModel @Inject constructor(
}
}
@Suppress("MaxLineLength")
private fun handleCloneClick() {
onContent { content ->
if (content.common.requiresReprompt) {
if (content.common.requiresCloneConfirmation) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(),
),
)
}
return@onContent
} else if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
@ -389,6 +402,23 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleConfirmCloneClick() {
onContent { content ->
mutableStateFlow.update {
it.copy(
dialog = null,
viewState = content.copy(
common = content.common.copy(
requiresCloneConfirmation = false,
),
),
)
}
trySendAction(VaultItemAction.Common.CloneClick)
}
}
private fun handleMoveToOrganizationClick() {
onContent { content ->
if (content.common.requiresReprompt) {
@ -1030,6 +1060,8 @@ data class VaultItemState(
* @property customFields A list of custom fields that user has added.
* @property requiresReprompt Indicates if a master password prompt is required to view
* secure fields.
* @property requiresCloneConfirmation Indicates user confirmation is required when
* cloning a cipher.
* @property currentCipher The cipher that is currently being viewed (nullable).
*/
@Parcelize
@ -1039,6 +1071,7 @@ data class VaultItemState(
val notes: String?,
val customFields: List<Custom>,
val requiresReprompt: Boolean,
val requiresCloneConfirmation: Boolean,
@IgnoredOnParcel
val currentCipher: CipherView? = null,
val attachments: List<AttachmentItem>?,
@ -1121,6 +1154,8 @@ data class VaultItemState(
* @property totpCodeItemData The optional data related the TOTP code.
* @property isPremiumUser Indicates if the user has subscribed to a premium
* account.
* @property fido2CredentialCreationDateText Optional creation date and time of the
* FIDO2 credential associated with the login item.
*/
@Parcelize
data class Login(
@ -1131,6 +1166,7 @@ data class VaultItemState(
val passwordRevisionDate: String?,
val totpCodeItemData: TotpCodeItemData?,
val isPremiumUser: Boolean,
val fido2CredentialCreationDateText: Text?,
) : ItemType() {
/**
@ -1246,6 +1282,14 @@ data class VaultItemState(
data class DeleteConfirmationPrompt(
val message: Text,
) : DialogState()
/**
* Displays the dialog for cloning without copying FIDO2 credentials to the user.
*/
@Parcelize
data class Fido2CredentialCannotBeCopiedConfirmationPrompt(
val message: Text,
) : DialogState()
}
}
@ -1430,6 +1474,11 @@ sealed class VaultItemAction {
* The user skipped selecting a location for the attachment file.
*/
data object NoAttachmentFileLocationReceive : Common()
/**
* The user confirmed cloning a cipher without its FIDO 2 credentials.
*/
data object ConfirmCloneWithoutFido2CredentialClick : Common()
}
/**

View file

@ -4,11 +4,15 @@ import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.Fido2Credential
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.capitalize
import com.x8bit.bitwarden.ui.platform.base.util.nullIfAllEqual
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
@ -22,7 +26,9 @@ import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull
import java.time.Clock
private const val DATE_TIME_PATTERN: String = "M/d/yy hh:mm a"
private const val LAST_UPDATED_DATE_TIME_PATTERN: String = "M/d/yy hh:mm a"
private const val FIDO2_CREDENTIAL_CREATION_DATE_PATTERN: String = "M/d/yy"
private const val FIDO2_CREDENTIAL_CREATION_TIME_PATTERN: String = "h:mm a"
/**
* Transforms [VaultData] into [VaultState.ViewState].
@ -40,10 +46,11 @@ fun CipherView.toViewState(
requiresReprompt = reprompt == CipherRepromptType.PASSWORD,
customFields = fields.orEmpty().map { it.toCustomField() },
lastUpdated = revisionDate.toFormattedPattern(
pattern = DATE_TIME_PATTERN,
pattern = LAST_UPDATED_DATE_TIME_PATTERN,
clock = clock,
),
notes = notes,
requiresCloneConfirmation = login?.fido2Credentials?.any() ?: false,
attachments = attachments
?.mapNotNull {
@Suppress("ComplexCondition")
@ -87,12 +94,16 @@ fun CipherView.toViewState(
passwordRevisionDate = loginValues
.passwordRevisionDate
?.toFormattedPattern(
pattern = DATE_TIME_PATTERN,
pattern = LAST_UPDATED_DATE_TIME_PATTERN,
clock = clock,
),
passwordHistoryCount = passwordHistory?.count(),
isPremiumUser = isPremiumUser,
totpCodeItemData = totpCodeItemData,
fido2CredentialCreationDateText = loginValues
.fido2Credentials
?.firstOrNull()
?.getCreationDateText(clock),
)
}
@ -159,6 +170,20 @@ private fun LoginUriView.toUriData() =
isLaunchable = !uri.isNullOrBlank(),
)
private fun Fido2Credential?.getCreationDateText(clock: Clock): Text? =
this?.let {
R.string.created_xy.asText(
creationDate.toFormattedPattern(
pattern = FIDO2_CREDENTIAL_CREATION_DATE_PATTERN,
clock = clock,
),
creationDate.toFormattedPattern(
pattern = FIDO2_CREDENTIAL_CREATION_TIME_PATTERN,
clock = clock,
),
)
}
private val IdentityView.identityAddress: String?
get() = listOfNotNull(
address1,

View file

@ -352,7 +352,7 @@ private const val CIPHER_JSON = """
"userDisplayName": "mockUserDisplayName-1",
"counter": "mockCounter-1",
"discoverable": "mockDiscoverable-1",
"creationDate": "2024-03-12T20:20:16.456Z"
"creationDate": "2023-10-27T12:00:00.000Z"
}
]
},

View file

@ -2,6 +2,11 @@ package com.x8bit.bitwarden.data.vault.datasource.network.model
import java.time.ZonedDateTime
/**
* Constant date time used for [ZonedDateTime] properties of mock objects.
*/
private val MOCK_ZONED_DATE_TIME = ZonedDateTime.parse("2023-10-27T12:00:00Z")
/**
* Create a mock [SyncResponseJson.Cipher] with a given [number].
*/
@ -15,9 +20,9 @@ fun createMockCipher(number: Int, hasNullUri: Boolean = false): SyncResponseJson
notes = "mockNotes-$number",
type = CipherTypeJson.LOGIN,
login = createMockLogin(number = number, hasNullUri = hasNullUri),
creationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
deletedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
creationDate = MOCK_ZONED_DATE_TIME,
deletedDate = MOCK_ZONED_DATE_TIME,
revisionDate = MOCK_ZONED_DATE_TIME,
attachments = listOf(createMockAttachment(number = number)),
card = createMockCard(number = number),
fields = listOf(createMockField(number = number)),
@ -89,7 +94,7 @@ fun createMockCard(number: Int): SyncResponseJson.Cipher.Card =
fun createMockPasswordHistory(number: Int): SyncResponseJson.Cipher.PasswordHistory =
SyncResponseJson.Cipher.PasswordHistory(
password = "mockPassword-$number",
lastUsedDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
lastUsedDate = MOCK_ZONED_DATE_TIME,
)
/**
@ -118,7 +123,7 @@ fun createMockLogin(number: Int, hasNullUri: Boolean = false): SyncResponseJson.
SyncResponseJson.Cipher.Login(
username = "mockUsername-$number",
password = "mockPassword-$number",
passwordRevisionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
passwordRevisionDate = MOCK_ZONED_DATE_TIME,
shouldAutofillOnPageLoad = false,
uri = if (hasNullUri) null else "mockUri-$number",
uris = listOf(createMockUri(number = number)),
@ -139,7 +144,7 @@ fun createMockFido2Credential(number: Int) = SyncResponseJson.Cipher.Fido2Creden
userDisplayName = "mockUserDisplayName-$number",
counter = "mockCounter-$number",
discoverable = "mockDiscoverable-$number",
creationDate = ZonedDateTime.parse("2024-03-12T20:20:16.456Z"),
creationDate = MOCK_ZONED_DATE_TIME,
)
/**

View file

@ -329,7 +329,7 @@ private const val CREATE_ATTACHMENT_SUCCESS_JSON = """
"userDisplayName": "mockUserDisplayName-1",
"counter": "mockCounter-1",
"discoverable": "mockDiscoverable-1",
"creationDate": "2024-03-12T20:20:16.456Z"
"creationDate": "2023-10-27T12:00:00.00Z"
}
]
},
@ -439,7 +439,7 @@ private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """
"userDisplayName": "mockUserDisplayName-1",
"counter": "mockCounter-1",
"discoverable": "mockDiscoverable-1",
"creationDate": "2024-03-12T20:20:16.456Z"
"creationDate": "2023-10-27T12:00:00.00Z"
}
]
},

View file

@ -245,7 +245,7 @@ private const val SYNC_SUCCESS_JSON = """
"userDisplayName": "mockUserDisplayName-1",
"counter": "mockCounter-1",
"discoverable": "mockDiscoverable-1",
"creationDate": "2024-03-12T20:20:16.456Z"
"creationDate": "2023-10-27T12:00:00.00Z"
}
]
},

View file

@ -15,8 +15,20 @@ import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.bitwarden.core.UriMatchType
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* Default date time used for [ZonedDateTime] properties of mock objects.
*/
private const val DEFAULT_TIMESTAMP = "2023-10-27T12:00:00Z"
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse(DEFAULT_TIMESTAMP),
ZoneOffset.UTC,
)
/**
* Create a mock [CipherView].
*
@ -24,12 +36,14 @@ import java.time.ZonedDateTime
* @param isDeleted whether or not the cipher has been deleted.
* @param cipherType the type of cipher to create.
*/
@Suppress("LongParameterList")
fun createMockCipherView(
number: Int,
isDeleted: Boolean = false,
cipherType: CipherType = CipherType.LOGIN,
totp: String? = "mockTotp-$number",
folderId: String? = "mockId-$number",
clock: Clock = FIXED_CLOCK,
): CipherView =
CipherView(
id = "mockId-$number",
@ -43,21 +57,16 @@ fun createMockCipherView(
login = createMockLoginView(
number = number,
totp = totp,
clock = clock,
)
.takeIf { cipherType == CipherType.LOGIN },
creationDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
creationDate = clock.instant(),
deletedDate = if (isDeleted) {
ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant()
clock.instant()
} else {
null
},
revisionDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
revisionDate = clock.instant(),
attachments = listOf(createMockAttachmentView(number = number)),
card = createMockCardView(number = number).takeIf { cipherType == CipherType.CARD },
fields = listOf(createMockFieldView(number = number)),
@ -65,7 +74,7 @@ fun createMockCipherView(
cipherType == CipherType.IDENTITY
},
favorite = false,
passwordHistory = listOf(createMockPasswordHistoryView(number = number)),
passwordHistory = listOf(createMockPasswordHistoryView(number = number, clock)),
reprompt = CipherRepromptType.NONE,
secureNote = createMockSecureNoteView().takeIf { cipherType == CipherType.SECURE_NOTE },
edit = false,
@ -80,40 +89,39 @@ fun createMockCipherView(
fun createMockLoginView(
number: Int,
totp: String? = "mockTotp-$number",
clock: Clock = FIXED_CLOCK,
): LoginView =
LoginView(
username = "mockUsername-$number",
password = "mockPassword-$number",
passwordRevisionDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
passwordRevisionDate = clock.instant(),
autofillOnPageLoad = false,
uris = listOf(createMockUriView(number = number)),
totp = totp,
fido2Credentials = createMockSdkFido2CredentialList(number),
fido2Credentials = createMockSdkFido2CredentialList(number, clock),
)
fun createMockSdkFido2CredentialList(number: Int) =
listOf(createMockSdkFido2CredentialView(number))
fun createMockSdkFido2CredentialList(number: Int, clock: Clock = FIXED_CLOCK) =
listOf(createMockSdkFido2CredentialView(number, clock))
fun createMockSdkFido2CredentialView(number: Int) =
Fido2Credential(
credentialId = "mockCredentialId-$number",
keyType = "mockKeyType-$number",
keyAlgorithm = "mockKeyAlgorithm-$number",
keyCurve = "mockKeyCurve-$number",
keyValue = "mockKeyValue-$number",
rpId = "mockRpId-$number",
userHandle = "mockUserHandle-$number",
userName = "mockUserName-$number",
counter = "mockCounter-$number",
rpName = "mockRpName-$number",
userDisplayName = "mockUserDisplayName-$number",
discoverable = "mockDiscoverable-$number",
creationDate = ZonedDateTime
.parse("2024-03-12T20:20:16.456Z")
.toInstant(),
)
fun createMockSdkFido2CredentialView(
number: Int,
clock: Clock = FIXED_CLOCK,
) = Fido2Credential(
credentialId = "mockCredentialId-$number",
keyType = "mockKeyType-$number",
keyAlgorithm = "mockKeyAlgorithm-$number",
keyCurve = "mockKeyCurve-$number",
keyValue = "mockKeyValue-$number",
rpId = "mockRpId-$number",
userHandle = "mockUserHandle-$number",
userName = "mockUserName-$number",
counter = "mockCounter-$number",
rpName = "mockRpName-$number",
userDisplayName = "mockUserDisplayName-$number",
discoverable = "mockDiscoverable-$number",
creationDate = clock.instant(),
)
/**
* Create a mock [LoginUriView] with a given [number].
@ -189,12 +197,10 @@ fun createMockIdentityView(number: Int): IdentityView =
/**
* Create a mock [PasswordHistoryView] with a given [number].
*/
fun createMockPasswordHistoryView(number: Int): PasswordHistoryView =
fun createMockPasswordHistoryView(number: Int, clock: Clock = FIXED_CLOCK): PasswordHistoryView =
PasswordHistoryView(
password = "mockPassword-$number",
lastUsedDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
lastUsedDate = clock.instant(),
)
/**

View file

@ -14,12 +14,24 @@ import com.bitwarden.core.PasswordHistory
import com.bitwarden.core.SecureNote
import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.UriMatchType
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* Default date time used for [ZonedDateTime] properties of mock objects.
*/
private const val DEFAULT_TIMESTAMP = "2023-10-27T12:00:00Z"
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse(DEFAULT_TIMESTAMP),
ZoneOffset.UTC,
)
/**
* Create a mock [Cipher] with a given [number].
*/
fun createMockSdkCipher(number: Int): Cipher =
fun createMockSdkCipher(number: Int, clock: Clock = FIXED_CLOCK): Cipher =
Cipher(
id = "mockId-$number",
organizationId = "mockOrganizationId-$number",
@ -29,22 +41,16 @@ fun createMockSdkCipher(number: Int): Cipher =
name = "mockName-$number",
notes = "mockNotes-$number",
type = CipherType.LOGIN,
login = createMockSdkLogin(number = number),
creationDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
deletedDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
revisionDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
login = createMockSdkLogin(number = number, clock = clock),
creationDate = clock.instant(),
deletedDate = clock.instant(),
revisionDate = clock.instant(),
attachments = listOf(createMockSdkAttachment(number = number)),
card = createMockSdkCard(number = number),
fields = listOf(createMockSdkField(number = number)),
identity = createMockSdkIdentity(number = number),
favorite = false,
passwordHistory = listOf(createMockSdkPasswordHistory(number = number)),
passwordHistory = listOf(createMockSdkPasswordHistory(number = number, clock = clock)),
reprompt = CipherRepromptType.NONE,
secureNote = createMockSdkSecureNote(),
edit = false,
@ -64,12 +70,10 @@ fun createMockSdkSecureNote(): SecureNote =
/**
* Create a mock [PasswordHistory] with a given [number].
*/
fun createMockSdkPasswordHistory(number: Int): PasswordHistory =
fun createMockSdkPasswordHistory(number: Int, clock: Clock): PasswordHistory =
PasswordHistory(
password = "mockPassword-$number",
lastUsedDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
lastUsedDate = clock.instant(),
)
/**
@ -137,17 +141,15 @@ fun createMockSdkAttachment(number: Int): Attachment =
/**
* Create a mock [Login] with a given [number].
*/
fun createMockSdkLogin(number: Int): Login =
fun createMockSdkLogin(number: Int, clock: Clock): Login =
Login(
username = "mockUsername-$number",
password = "mockPassword-$number",
passwordRevisionDate = ZonedDateTime
.parse("2023-10-27T12:00:00Z")
.toInstant(),
passwordRevisionDate = clock.instant(),
autofillOnPageLoad = false,
uris = listOf(createMockSdkUri(number = number)),
totp = "mockTotp-$number",
fido2Credentials = createMockSdkFido2CredentialList(number),
fido2Credentials = createMockSdkFido2CredentialList(number, clock),
)
/**

View file

@ -62,7 +62,7 @@ class TotpCodeManagerTest {
)
val cipherView = createMockCipherView(1).copy(
login = createMockLoginView(1).copy(
login = createMockLoginView(number = 1, clock = clock).copy(
totp = null,
),
)
@ -82,7 +82,7 @@ class TotpCodeManagerTest {
)
val cipherView = createMockCipherView(1).copy(
login = createMockLoginView(1).copy(
login = createMockLoginView(number = 1, clock = clock).copy(
totp = null,
),
)

View file

@ -280,7 +280,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = userId,
cipherList = listOf(createMockSdkCipher(1)),
cipherList = listOf(createMockSdkCipher(1, clock)),
)
} returns listOf(createMockCipherView(number = 1)).asSuccess()
coEvery {
@ -1834,7 +1834,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.createCipher(
body = createMockCipherJsonRequest(number = 1, hasNullUri = true),
@ -1861,7 +1861,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
val mockCipher = createMockCipher(number = 1)
coEvery {
ciphersService.createCipher(
@ -1931,7 +1931,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.createCipherInOrganization(
body = CreateCipherInOrganizationJsonRequest(
@ -1964,7 +1964,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
val mockCipher = createMockCipher(number = 1)
coEvery {
ciphersService.createCipherInOrganization(
@ -2043,7 +2043,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.updateCipher(
cipherId = cipherId,
@ -2072,7 +2072,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.updateCipher(
cipherId = cipherId,
@ -2111,7 +2111,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = mockCipherView,
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
val mockCipher = createMockCipher(number = 1)
coEvery {
ciphersService.updateCipher(
@ -2221,7 +2221,7 @@ class VaultRepositoryTest {
runTest {
mockkStatic(Cipher::toEncryptedNetworkCipherResponse)
every {
createMockSdkCipher(number = 1).toEncryptedNetworkCipherResponse()
createMockSdkCipher(number = 1, clock = clock).toEncryptedNetworkCipherResponse()
} returns createMockCipher(number = 1)
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
val userId = "mockId-1"
@ -2234,7 +2234,7 @@ class VaultRepositoryTest {
deletedDate = fixedInstant,
),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
coEvery {
@ -2303,7 +2303,7 @@ class VaultRepositoryTest {
runTest {
mockkStatic(Cipher::toEncryptedNetworkCipherResponse)
every {
createMockSdkCipher(number = 1).toEncryptedNetworkCipherResponse()
createMockSdkCipher(number = 1, clock = clock).toEncryptedNetworkCipherResponse()
} returns createMockCipher(number = 1)
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
val userId = "mockId-1"
@ -2316,7 +2316,7 @@ class VaultRepositoryTest {
attachments = emptyList(),
),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
ciphersService.deleteCipherAttachment(
@ -2385,7 +2385,7 @@ class VaultRepositoryTest {
runTest {
mockkStatic(Cipher::toEncryptedNetworkCipherResponse)
every {
createMockSdkCipher(number = 1).toEncryptedNetworkCipherResponse()
createMockSdkCipher(number = 1, clock = clock).toEncryptedNetworkCipherResponse()
} returns createMockCipher(number = 1)
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
val userId = "mockId-1"
@ -2398,7 +2398,7 @@ class VaultRepositoryTest {
deletedDate = null,
),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { ciphersService.restoreCipher(cipherId = cipherId) } returns Unit.asSuccess()
coEvery {
@ -2957,7 +2957,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.shareCipher(
cipherId = "mockId-1",
@ -2998,7 +2998,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.shareCipher(
cipherId = "mockId-1",
@ -3085,7 +3085,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.updateCipherCollections(
cipherId = "mockId-1",
@ -3120,7 +3120,7 @@ class VaultRepositoryTest {
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
coEvery {
ciphersService.updateCipherCollections(
cipherId = "mockId-1",
@ -3233,7 +3233,7 @@ class VaultRepositoryTest {
val cipherId = "cipherId-1"
val mockUri = setupMockUri(url = "www.test.com")
val mockCipherView = createMockCipherView(number = 1)
val mockCipher = createMockSdkCipher(number = 1)
val mockCipher = createMockSdkCipher(number = 1, clock = clock)
val mockFileName = "mockFileName-1"
val mockFileSize = "1"
val mockAttachmentView = createMockAttachmentView(number = 1).copy(
@ -3278,7 +3278,7 @@ class VaultRepositoryTest {
val cipherId = "cipherId-1"
val mockUri = setupMockUri(url = "www.test.com")
val mockCipherView = createMockCipherView(number = 1)
val mockCipher = createMockSdkCipher(number = 1)
val mockCipher = createMockSdkCipher(number = 1, clock = clock)
val mockFileName = "mockFileName-1"
val mockFileSize = "1"
coEvery {
@ -3308,7 +3308,7 @@ class VaultRepositoryTest {
val cipherId = "cipherId-1"
val mockUri = setupMockUri(url = "www.test.com")
val mockCipherView = createMockCipherView(number = 1)
val mockCipher = createMockSdkCipher(number = 1)
val mockCipher = createMockSdkCipher(number = 1, clock = clock)
val mockFileName = "mockFileName-1"
val mockFileSize = "1"
val mockAttachmentView = createMockAttachmentView(number = 1).copy(
@ -3364,7 +3364,7 @@ class VaultRepositoryTest {
val cipherId = "cipherId-1"
val mockUri = setupMockUri(url = "www.test.com")
val mockCipherView = createMockCipherView(number = 1)
val mockCipher = createMockSdkCipher(number = 1)
val mockCipher = createMockSdkCipher(number = 1, clock = clock)
val mockFileName = "mockFileName-1"
val mockFileSize = "1"
val mockAttachmentView = createMockAttachmentView(number = 1).copy(
@ -3427,7 +3427,7 @@ class VaultRepositoryTest {
val cipherId = "cipherId-1"
val mockUri = setupMockUri(url = "www.test.com")
val mockCipherView = createMockCipherView(number = 1)
val mockCipher = createMockSdkCipher(number = 1)
val mockCipher = createMockSdkCipher(number = 1, clock = clock)
val mockFileName = "mockFileName-1"
val mockFileSize = "1"
val mockAttachmentView = createMockAttachmentView(number = 1).copy(
@ -3503,7 +3503,7 @@ class VaultRepositoryTest {
val cipherId = "cipherId-1"
val mockUri = setupMockUri(url = "www.test.com")
val mockCipherView = createMockCipherView(number = 1)
val mockCipher = createMockSdkCipher(number = 1)
val mockCipher = createMockSdkCipher(number = 1, clock = clock)
val mockFileName = "mockFileName-1"
val mockFileSize = "1"
val mockAttachmentView = createMockAttachmentView(number = 1).copy(
@ -4456,7 +4456,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
cipherList = listOf(createMockSdkCipher(number = number, clock = clock)),
)
} returns listOf(cipherView).asSuccess()
@ -4499,7 +4499,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
cipherList = listOf(createMockSdkCipher(number = number, clock = clock)),
)
} returns listOf(cipherView).asSuccess()
val collectionView = createMockCollectionView(number = number)
@ -4678,7 +4678,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
cipherList = listOf(createMockSdkCipher(number = number, clock = clock)),
)
} returns listOf(cipherView).asSuccess()
@ -4734,7 +4734,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
cipherList = listOf(createMockSdkCipher(number = number, clock = clock)),
)
} returns listOf(cipherView).asSuccess()
@ -4888,7 +4888,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = MOCK_USER_STATE.activeUserId,
cipherList = listOf(createMockSdkCipher(number = number)),
cipherList = listOf(createMockSdkCipher(number = number, clock = clock)),
)
} returns listOf(cipherView).asSuccess()
@ -5734,7 +5734,7 @@ class VaultRepositoryTest {
coEvery {
vaultSdkSource.decryptCipherList(
userId = userId,
cipherList = listOf(createMockSdkCipher(1)),
cipherList = listOf(createMockSdkCipher(1, clock)),
)
} returns listOf(createMockCipherView(1)).asSuccess()
coEvery {

View file

@ -30,12 +30,25 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSecureNo
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkUri
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* Default date time used for [ZonedDateTime] properties of mock objects.
*/
private const val DEFAULT_TIMESTAMP = "2023-10-27T12:00:00Z"
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse(DEFAULT_TIMESTAMP),
ZoneOffset.UTC,
)
class VaultSdkCipherExtensionsTest {
@Test
fun `toEncryptedNetworkCipherResponse should convert an Sdk Cipher to a cipher`() {
val sdkCipher = createMockSdkCipher(number = 1)
val sdkCipher = createMockSdkCipher(number = 1, clock = FIXED_CLOCK)
val result = sdkCipher.toEncryptedNetworkCipherResponse()
@ -50,7 +63,7 @@ class VaultSdkCipherExtensionsTest {
@Test
fun `toEncryptedNetworkCipher should convert an Sdk Cipher to a Network Cipher`() {
val sdkCipher = createMockSdkCipher(number = 1)
val sdkCipher = createMockSdkCipher(number = 1, clock = FIXED_CLOCK)
val syncCipher = sdkCipher.toEncryptedNetworkCipher()
assertEquals(
createMockCipherJsonRequest(
@ -70,8 +83,8 @@ class VaultSdkCipherExtensionsTest {
val sdkCiphers = syncCiphers.toEncryptedSdkCipherList()
assertEquals(
listOf(
createMockSdkCipher(number = 1),
createMockSdkCipher(number = 2),
createMockSdkCipher(number = 1, clock = FIXED_CLOCK),
createMockSdkCipher(number = 2, clock = FIXED_CLOCK),
),
sdkCiphers,
)
@ -82,7 +95,7 @@ class VaultSdkCipherExtensionsTest {
val syncCipher = createMockCipher(number = 1)
val sdkCipher = syncCipher.toEncryptedSdkCipher()
assertEquals(
createMockSdkCipher(number = 1),
createMockSdkCipher(number = 1, clock = FIXED_CLOCK),
sdkCipher,
)
}
@ -92,7 +105,7 @@ class VaultSdkCipherExtensionsTest {
val syncLogin = createMockLogin(number = 1)
val sdkLogin = syncLogin.toSdkLogin()
assertEquals(
createMockSdkLogin(number = 1),
createMockSdkLogin(number = 1, clock = FIXED_CLOCK),
sdkLogin,
)
}
@ -215,8 +228,8 @@ class VaultSdkCipherExtensionsTest {
val sdkPasswordHistories = syncPasswordHistories.toSdkPasswordHistoryList()
assertEquals(
listOf(
createMockSdkPasswordHistory(number = 1),
createMockSdkPasswordHistory(number = 2),
createMockSdkPasswordHistory(number = 1, FIXED_CLOCK),
createMockSdkPasswordHistory(number = 2, FIXED_CLOCK),
),
sdkPasswordHistories,
)
@ -227,7 +240,7 @@ class VaultSdkCipherExtensionsTest {
val syncPasswordHistory = createMockPasswordHistory(number = 1)
val sdkPasswordHistory = syncPasswordHistory.toSdkPasswordHistory()
assertEquals(
createMockSdkPasswordHistory(number = 1),
createMockSdkPasswordHistory(number = 1, clock = FIXED_CLOCK),
sdkPasswordHistory,
)
}

View file

@ -210,7 +210,7 @@ class SearchViewModelTest : BaseViewModelTest() {
val cipherId = CIPHER_ID
val errorMessage = "Server error"
val updatedCipherView = cipherView.copy(
login = createMockLoginView(1).copy(
login = createMockLoginView(number = 1, clock = clock).copy(
uris = listOf(createMockUriView(number = 1)) +
LoginUriView(
uri = AUTOFILL_URI,
@ -261,7 +261,7 @@ class SearchViewModelTest : BaseViewModelTest() {
val cipherView = setupForAutofill()
val cipherId = CIPHER_ID
val updatedCipherView = cipherView.copy(
login = createMockLoginView(1).copy(
login = createMockLoginView(number = 1, clock = clock).copy(
uris = listOf(createMockUriView(number = 1)) +
LoginUriView(
uri = AUTOFILL_URI,
@ -420,7 +420,7 @@ class SearchViewModelTest : BaseViewModelTest() {
val cipherId = CIPHER_ID
val password = "password"
val updatedCipherView = cipherView.copy(
login = createMockLoginView(1).copy(
login = createMockLoginView(number = 1, clock = clock).copy(
uris = listOf(createMockUriView(number = 1)) +
LoginUriView(
uri = AUTOFILL_URI,

View file

@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
@ -70,11 +71,18 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
@Suppress("LargeClass")
class VaultAddEditViewModelTest : BaseViewModelTest() {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val settingsRepository: SettingsRepository = mockk {
every { initialAutofillDialogShown = any() } just runs
every { initialAutofillDialogShown } returns true
@ -476,6 +484,10 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
uri = listOf(UriItem("testId", "www.mockuri1.com", UriMatchType.HOST)),
totpCode = "mockTotp-1",
canViewPassword = true,
fido2CredentialCreationDateTime = R.string.created_xy.asText(
"10/27/23",
"12:00 PM",
),
)
.copy(totp = "mockTotp-1"),
),
@ -752,6 +764,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
} returns stateWithName.viewState
mutableVaultDataFlow.value = DataState.Loaded(
@ -781,6 +794,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
}
@ -813,6 +827,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
} returns stateWithName.viewState
coEvery {
@ -873,6 +888,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = fixedClock,
)
} returns stateWithName.viewState
coEvery {
@ -1857,6 +1873,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
resourceManager = resourceManager,
authRepository = authRepository,
settingsRepository = settingsRepository,
clock = fixedClock,
)
}
@ -2369,12 +2386,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
availableOwners = availableOwners,
)
@Suppress("LongParameterList")
private fun createLoginTypeContentViewState(
username: String = "",
password: String = "",
uri: List<UriItem> = listOf(UriItem("testId", "", null)),
totpCode: String? = null,
canViewPassword: Boolean = true,
fido2CredentialCreationDateTime: Text? = null,
): VaultAddEditState.ViewState.Content.ItemType.Login =
VaultAddEditState.ViewState.Content.ItemType.Login(
username = username,
@ -2382,6 +2401,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
uriList = uri,
totp = totpCode,
canViewPassword = canViewPassword,
fido2CredentialCreationDateTime = fido2CredentialCreationDateTime,
)
private fun createSavedStateHandleWithState(
@ -2400,12 +2420,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
set("vault_edit_id", (vaultAddEditType as? VaultAddEditType.EditItem)?.vaultItemId)
}
@Suppress("LongParameterList")
private fun createAddVaultItemViewModel(
savedStateHandle: SavedStateHandle = loginInitialSavedStateHandle,
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
vaultRepo: VaultRepository = vaultRepository,
generatorRepo: GeneratorRepository = generatorRepository,
bitwardenResourceManager: ResourceManager = resourceManager,
clock: Clock = fixedClock,
): VaultAddEditViewModel =
VaultAddEditViewModel(
savedStateHandle = savedStateHandle,
@ -2417,6 +2439,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
resourceManager = bitwardenResourceManager,
authRepository = authRepository,
settingsRepository = settingsRepository,
clock = clock,
)
private fun createVaultData(

View file

@ -4,6 +4,7 @@ import com.bitwarden.core.CardView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.Fido2Credential
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView
@ -20,7 +21,6 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
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.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
@ -37,7 +37,9 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class CipherViewExtensionsTest {
@ -66,6 +68,7 @@ class CipherViewExtensionsTest {
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = FIXED_CLOCK,
)
assertEquals(
@ -110,6 +113,7 @@ class CipherViewExtensionsTest {
isClone = false,
isIndividualVaultDisabled = true,
resourceManager = resourceManager,
clock = FIXED_CLOCK,
)
assertEquals(
@ -159,6 +163,7 @@ class CipherViewExtensionsTest {
isClone = false,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = FIXED_CLOCK,
)
assertEquals(
@ -189,6 +194,10 @@ class CipherViewExtensionsTest {
uriList = listOf(UriItem(TEST_ID, "www.example.com", null)),
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
canViewPassword = false,
fido2CredentialCreationDateTime = R.string.created_xy.asText(
"10/27/23",
"12:00 PM",
),
),
),
result,
@ -203,6 +212,7 @@ class CipherViewExtensionsTest {
isClone = false,
isIndividualVaultDisabled = true,
resourceManager = resourceManager,
clock = FIXED_CLOCK,
)
assertEquals(
@ -236,6 +246,7 @@ class CipherViewExtensionsTest {
isClone = true,
isIndividualVaultDisabled = false,
resourceManager = resourceManager,
clock = FIXED_CLOCK,
)
assertEquals(
@ -423,6 +434,11 @@ class CipherViewExtensionsTest {
)
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
id = "id1234",
organizationId = null,
@ -472,12 +488,12 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(
passwordHistory = listOf(
PasswordHistoryView(
password = "old_password",
lastUsedDate = Instant.ofEpochSecond(1_000L),
lastUsedDate = FIXED_CLOCK.instant(),
),
),
creationDate = Instant.ofEpochSecond(1_000L),
creationDate = FIXED_CLOCK.instant(),
deletedDate = null,
revisionDate = Instant.ofEpochSecond(1_000L),
revisionDate = FIXED_CLOCK.instant(),
)
private val DEFAULT_CARD_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
@ -521,7 +537,7 @@ private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.cop
login = LoginView(
username = "username",
password = "password",
passwordRevisionDate = Instant.ofEpochSecond(1_000L),
passwordRevisionDate = FIXED_CLOCK.instant(),
uris = listOf(
LoginUriView(
uri = "www.example.com",
@ -530,7 +546,23 @@ private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.cop
),
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
autofillOnPageLoad = false,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
fido2Credentials = listOf(
Fido2Credential(
credentialId = "mockCredentialId",
keyType = "mockKeyType",
keyAlgorithm = "mockKeyAlgorithm",
keyCurve = "mockKeyCurve",
keyValue = "mockKeyValue",
rpId = "mockRpId",
userHandle = "mockUserHandle",
userName = "mockUserName",
counter = "mockCounter",
rpName = "mockRpName",
userDisplayName = "mockUserDisplayName",
discoverable = "mockDiscoverable",
creationDate = FIXED_CLOCK.instant(),
),
),
),
)

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.R
@ -1214,6 +1215,7 @@ class VaultItemScreenTest : BaseComposeTest() {
passwordRevisionDate = null,
isPremiumUser = true,
totpCodeItemData = null,
fido2CredentialCreationDateText = null,
),
),
)
@ -1301,6 +1303,53 @@ class VaultItemScreenTest : BaseComposeTest() {
}
}
@Test
fun `in login state, the Passkey field should exist based on the state`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
type = EMPTY_LOGIN_TYPE.copy(
fido2CredentialCreationDateText = DEFAULT_PASSKEY,
),
),
)
}
composeTestRule
.onNode(isProgressBar)
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Passkey")
.assertIsDisplayed()
}
@Test
fun `in login state, the Passkey field should not exist based on state`() {
mutableStateFlow.update { it }
composeTestRule
.onNodeWithText("Passkey")
.assertDoesNotExist()
}
@Test
fun `in login state, the Passkey field text should display creation date`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_LOGIN_VIEW_STATE.copy(
type = EMPTY_LOGIN_TYPE.copy(
fido2CredentialCreationDateText = DEFAULT_PASSKEY,
),
),
)
}
composeTestRule
.onNodeWithText(text = DEFAULT_PASSKEY.toString(), substring = true)
.assertIsDisplayed()
}
@Test
fun `in login state, the TOTP field should exist based on the state`() {
mutableStateFlow.update { currentState ->
@ -1338,6 +1387,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNode(isProgressBar)
.performScrollTo()
.assertIsDisplayed()
composeTestRule
@ -1355,6 +1405,7 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule
.onNode(isProgressBar)
.performScrollTo()
.assertIsDisplayed()
composeTestRule
@ -2034,8 +2085,8 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState(
private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
VaultItemState.ViewState.Content.Common(
lastUpdated = "12/31/69 06:16 PM",
name = "cipher",
lastUpdated = "12/31/69 06:16 PM",
notes = "Lots of notes",
customFields = listOf(
VaultItemState.ViewState.Content.Common.Custom.TextField(
@ -2055,6 +2106,7 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
),
),
requiresReprompt = true,
requiresCloneConfirmation = false,
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
@ -2067,6 +2119,11 @@ private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
),
)
private val DEFAULT_PASSKEY = R.string.created_xy.asText(
"3/13/24",
"3:56 PM",
)
private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
VaultItemState.ViewState.Content.ItemType.Login(
passwordHistoryCount = 1,
@ -2091,6 +2148,7 @@ private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
verificationCode = "123456",
totpCode = "testCode",
),
fido2CredentialCreationDateText = null,
)
private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity =
@ -2122,6 +2180,7 @@ private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
notes = null,
customFields = emptyList(),
requiresReprompt = true,
requiresCloneConfirmation = false,
attachments = emptyList(),
)
@ -2134,6 +2193,7 @@ private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
passwordRevisionDate = null,
totpCodeItemData = null,
isPremiumUser = true,
fido2CredentialCreationDateText = null,
)
private val EMPTY_IDENTITY_TYPE: VaultItemState.ViewState.Content.ItemType.Identity =

View file

@ -932,6 +932,103 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on CloneClick should show confirmation when cipher contains a passkey`() = runTest {
val loginViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
requiresReprompt = false,
requiresCloneConfirmation = true,
),
)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val mockCipherView = mockk<CipherView> {
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Common.CloneClick)
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(),
),
),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `on CloneClick should show confirmation before re-prompt when both are required`() {
val loginViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
requiresReprompt = true,
requiresCloneConfirmation = true,
),
)
val loginState = DEFAULT_STATE.copy(
viewState = loginViewState,
)
val mockCipherView = mockk<CipherView> {
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Common.CloneClick)
// Assert clone confirmation dialog is triggered
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(),
),
),
viewModel.stateFlow.value,
)
// Simulate confirmation click.
viewModel.trySendAction(VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick)
// Assert MP dialog is triggered.
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CloneClick,
),
viewState = loginViewState.copy(
common = loginViewState.common.copy(
requiresCloneConfirmation = false,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on CloneClick should show password dialog when re-prompt is required`() = runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
@ -1946,6 +2043,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
timeLeftSeconds = 15,
periodSeconds = 30,
),
fido2CredentialCreationDateText = null,
)
private val DEFAULT_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =
@ -1988,6 +2086,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
),
),
requiresReprompt = true,
requiresCloneConfirmation = false,
currentCipher = createMockCipherView(number = 1),
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(

View file

@ -4,13 +4,15 @@ import com.bitwarden.core.AttachmentView
import com.bitwarden.core.CipherRepromptType
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.Fido2Credential
import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
@ -43,7 +45,23 @@ fun createLoginView(isEmpty: Boolean): LoginView =
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"
.takeUnless { isEmpty },
autofillOnPageLoad = false,
fido2Credentials = createMockSdkFido2CredentialList(number = 1),
fido2Credentials = listOf(
Fido2Credential(
credentialId = "mockCredentialId",
keyType = "mockKeyType",
keyAlgorithm = "mockKeyAlgorithm",
keyCurve = "mockKeyCurve",
keyValue = "mockKeyValue",
rpId = "mockRpId",
userHandle = "mockUserHandle",
userName = "mockUserName",
counter = "mockCounter",
rpName = "mockRpName",
userDisplayName = "mockUserDisplayName",
discoverable = "mockDiscoverable",
creationDate = Instant.ofEpochSecond(1_000L),
),
).takeUnless { isEmpty },
)
@Suppress("CyclomaticComplexMethod")
@ -156,6 +174,7 @@ fun createCommonContent(
notes = null,
customFields = emptyList(),
requiresReprompt = true,
requiresCloneConfirmation = false,
attachments = emptyList(),
)
} else {
@ -189,6 +208,7 @@ fun createCommonContent(
),
),
requiresReprompt = true,
requiresCloneConfirmation = true,
attachments = listOf(
VaultItemState.ViewState.Content.Common.AttachmentItem(
id = "attachment-id",
@ -232,6 +252,11 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
totpCode = "testCode",
)
.takeUnless { isEmpty },
fido2CredentialCreationDateText = R.string.created_xy.asText(
"1/1/70",
"12:16 AM",
)
.takeUnless { isEmpty },
)
fun createIdentityContent(

View file

@ -57,6 +57,7 @@ class VaultAddItemStateExtensionsTest {
password = "mockPassword-1",
uriList = listOf(UriItem("testId", "mockUri-1", UriMatchType.DOMAIN)),
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
fido2CredentialCreationDateTime = null,
),
)