BIT-1339 Add TOTP item to detail screen (#744)

This commit is contained in:
Oleg Semenenko 2024-01-24 18:31:13 -06:00 committed by Álison Fernandes
parent 6223b225c5
commit dc3081c5d6
16 changed files with 756 additions and 123 deletions

View file

@ -74,13 +74,12 @@ class TotpCodeManagerImpl(
@Suppress("LongMethod")
private fun getTotpCodeStateFlowInternal(
userId: String,
cipher: CipherView,
cipher: CipherView?,
): StateFlow<DataState<VerificationCodeItem?>> {
val cipherId = cipher.id ?: return MutableStateFlow(DataState.Loaded(null))
val cipherId = cipher?.id ?: return MutableStateFlow(DataState.Loaded(null))
return mutableVerificationCodeStateFlowMap.getOrPut(cipher) {
flow<DataState<VerificationCodeItem?>> {
val totpCode = cipher
.login
?.totp

View file

@ -128,6 +128,12 @@ interface VaultRepository : VaultLockManager {
*/
fun getSendStateFlow(sendId: String): StateFlow<DataState<SendView?>>
/**
* Flow that represents the data for a single verification code item.
* This may emit null if any issues arise during code generation.
*/
fun getAuthCodeFlow(cipherId: String): StateFlow<DataState<VerificationCodeItem?>>
/**
* Flow that represents the data for the TOTP verification codes for ciphers items.
* This may emit an empty list if any issues arise during code generation.

View file

@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -363,6 +364,37 @@ class VaultRepositoryImpl(
)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun getAuthCodeFlow(cipherId: String): StateFlow<DataState<VerificationCodeItem?>> {
val userId = requireNotNull(activeUserId)
return getVaultItemStateFlow(cipherId)
.flatMapLatest { cipherDataState ->
val cipher = cipherDataState.data
?: return@flatMapLatest flowOf(DataState.Loaded(null))
totpCodeManager
.getTotpCodeStateFlow(
userId = userId,
cipher = cipher,
)
.map { totpCodeDataState ->
val totpCodeData = totpCodeDataState.data
combineDataStates(
totpCodeDataState.map { Unit },
cipherDataState,
) { _, _ ->
// Just return the verification items; we are only combining the
// DataStates to know the overall state.
totpCodeData
}
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.WhileSubscribed(),
initialValue = DataState.Loading,
)
}
override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) {
mutableTotpCodeResultFlow.tryEmit(totpCodeResult)
}

View file

@ -0,0 +1,58 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
/**
* A countdown timer displayed to the user.
*
* @param timeLeftSeconds The seconds left on the timer.
* @param periodSeconds The period for the timer countdown.
* @param modifier A [Modifier] for the composable.
*/
@Composable
fun BitwardenCircularCountdownIndicator(
timeLeftSeconds: Int,
periodSeconds: Int,
modifier: Modifier = Modifier,
) {
val progressAnimate by animateFloatAsState(
targetValue = timeLeftSeconds.toFloat() / periodSeconds,
animationSpec = tween(
durationMillis = periodSeconds,
delayMillis = 0,
easing = LinearOutSlowInEasing,
),
)
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
CircularProgressIndicator(
progress = { progressAnimate },
modifier = Modifier.size(size = 30.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
)
Text(
text = timeLeftSeconds.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenCircularCountdownIndicator
import com.x8bit.bitwarden.ui.platform.components.BitwardenHiddenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
@ -28,6 +29,9 @@ import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
private const val AUTH_CODE_SPACING_INTERVAL = 3
/**
* The top level content UI state for the [VaultItemScreen] when viewing a Login cipher.
@ -94,14 +98,18 @@ fun VaultItemLoginContent(
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
TotpField(
isPremiumUser = loginItemState.isPremiumUser,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
loginItemState.totpCodeItemData?.let { totpCodeItemData ->
item {
Spacer(modifier = Modifier.height(8.dp))
TotpField(
totpCodeItemData = totpCodeItemData,
isPremiumUser = loginItemState.isPremiumUser,
onCopyTotpClick = vaultLoginItemTypeHandlers.onCopyTotpCodeClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
loginItemState.uris.takeUnless { it.isEmpty() }?.let { uris ->
@ -298,11 +306,37 @@ private fun PasswordHistoryCount(
@Composable
private fun TotpField(
totpCodeItemData: TotpCodeItemData,
isPremiumUser: Boolean,
onCopyTotpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (isPremiumUser) {
// TODO: Insert TOTP values here (BIT-1214)
Row {
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.verification_code_totp),
value = totpCodeItemData.verificationCode
.chunked(AUTH_CODE_SPACING_INTERVAL)
.joinToString(" "),
onValueChange = { },
readOnly = true,
singleLine = true,
actions = {
BitwardenCircularCountdownIndicator(
timeLeftSeconds = totpCodeItemData.timeLeftSeconds,
periodSeconds = totpCodeItemData.periodSeconds,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy_totp),
),
onClick = onCopyTotpClick,
)
},
modifier = modifier,
)
}
} else {
BitwardenTextField(
label = stringResource(id = R.string.verification_code_totp),

View file

@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
@ -18,6 +20,8 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemStateData
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
@ -58,10 +62,28 @@ class VaultItemViewModel @Inject constructor(
combine(
vaultRepository.getVaultItemStateFlow(state.vaultItemId),
authRepository.userStateFlow,
) { cipherViewState, userState ->
vaultRepository.getAuthCodeFlow(state.vaultItemId),
) { cipherViewState, userState, authCodeState ->
val totpCodeData = authCodeState.data?.let {
TotpCodeItemData(
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
totpCode = it.totpCode,
verificationCode = it.code,
)
}
VaultItemAction.Internal.VaultDataReceive(
userState = userState,
vaultDataState = cipherViewState,
vaultDataState = combineDataStates(
cipherViewState,
authCodeState.map { Unit },
) { vaultData, _ ->
VaultItemStateData(
cipher = vaultData,
totpCodeItemData = totpCodeData,
)
},
)
}
.onEach(::sendAction)
@ -249,7 +271,7 @@ class VaultItemViewModel @Inject constructor(
),
)
}
}
}
}
}
@ -294,6 +316,10 @@ class VaultItemViewModel @Inject constructor(
handleCopyPasswordClick()
}
is VaultItemAction.ItemType.Login.CopyTotpClick -> {
handleCopyTotpClick()
}
is VaultItemAction.ItemType.Login.CopyUriClick -> {
handleCopyUriClick(action)
}
@ -342,6 +368,13 @@ class VaultItemViewModel @Inject constructor(
}
}
private fun handleCopyTotpClick() {
onLoginContent { _, login ->
val code = login.totpCodeItemData?.verificationCode ?: return@onLoginContent
clipboardManager.setText(text = code)
}
}
private fun handleCopyUriClick(action: VaultItemAction.ItemType.Login.CopyUriClick) {
clipboardManager.setText(text = action.uri)
}
@ -470,6 +503,7 @@ class VaultItemViewModel @Inject constructor(
}
}
@Suppress("LongMethod")
private fun handleVaultDataReceive(action: VaultItemAction.Internal.VaultDataReceive) {
// Leave the current data alone if there is no UserState; we are in the process of logging
// out.
@ -489,8 +523,13 @@ class VaultItemViewModel @Inject constructor(
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState.data
?.toViewState(isPremiumUser = userState.activeAccount.isPremium)
viewState = vaultDataState
.data
.cipher
?.toViewState(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data.totpCodeItemData,
)
?: VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
@ -519,8 +558,13 @@ class VaultItemViewModel @Inject constructor(
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState.data
?.toViewState(isPremiumUser = userState.activeAccount.isPremium)
viewState = vaultDataState
.data
.cipher
?.toViewState(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data.totpCodeItemData,
)
?: VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
@ -572,6 +616,7 @@ class VaultItemViewModel @Inject constructor(
)
}
}
DeleteCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(VaultItemEvent.ShowToast(message = R.string.item_soft_deleted.asText()))
@ -786,7 +831,7 @@ data class VaultItemState(
* @property uris The URI associated with the login item.
* @property passwordRevisionDate An optional string indicating the last time the
* password was changed.
* @property totp The optional TOTP string value to be displayed.
* @property totpCodeItemData The optional data related the TOTP code.
* @property isPremiumUser Indicates if the user has subscribed to a premium
* account.
*/
@ -797,7 +842,7 @@ data class VaultItemState(
val passwordHistoryCount: Int?,
val uris: List<UriData>,
val passwordRevisionDate: String?,
val totp: String?,
val totpCodeItemData: TotpCodeItemData?,
val isPremiumUser: Boolean,
) : ItemType() {
@ -1077,6 +1122,11 @@ sealed class VaultItemAction {
*/
data object CopyPasswordClick : Login()
/**
* The user has clicked the copy button for the TOTP code.
*/
data object CopyTotpClick : Login()
/**
* The user has clicked the copy button for a URI.
*/
@ -1142,7 +1192,7 @@ sealed class VaultItemAction {
*/
data class VaultDataReceive(
val userState: UserState?,
val vaultDataState: DataState<CipherView?>,
val vaultDataState: DataState<VaultItemStateData>,
) : Internal()
/**

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel
data class VaultLoginItemTypeHandlers(
val onCheckForBreachClick: () -> Unit,
val onCopyPasswordClick: () -> Unit,
val onCopyTotpCodeClick: () -> Unit,
val onCopyUriClick: (String) -> Unit,
val onCopyUsernameClick: () -> Unit,
val onLaunchUriClick: (String) -> Unit,
@ -32,6 +33,9 @@ data class VaultLoginItemTypeHandlers(
onCopyPasswordClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick)
},
onCopyTotpCodeClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyTotpClick)
},
onCopyUriClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUriClick(it))
},

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.ui.vault.feature.item.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* The data relating to the verification code.
*
* @property periodSeconds The period for the verification code.
* @property timeLeftSeconds The time left for the verification timer.
* @property verificationCode The verification code for the item.
* @property totpCode The totp code for the item.
*/
@Parcelize
data class TotpCodeItemData(
val periodSeconds: Int,
val timeLeftSeconds: Int,
val verificationCode: String,
val totpCode: String,
) : Parcelable

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.ui.vault.feature.item.model
import com.bitwarden.core.CipherView
/**
* The state containing totp code item information and the cipher for the item.
*
* @property cipher The cipher view for the item.
* @property totpCodeItemData The data for the totp code.
*/
data class VaultItemStateData(
val cipher: CipherView?,
val totpCodeItemData: TotpCodeItemData?,
)

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.nullIfAllEqual
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.ui.platform.base.util.orZeroWidthSpace
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.feature.vault.VaultState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
@ -29,8 +30,10 @@ private val dateTimeFormatter
/**
* Transforms [VaultData] into [VaultState.ViewState].
*/
@Suppress("LongMethod")
fun CipherView.toViewState(
isPremiumUser: Boolean,
totpCodeItemData: TotpCodeItemData?,
): VaultItemState.ViewState =
VaultItemState.ViewState.Content(
common = VaultItemState.ViewState.Content.Common(
@ -58,8 +61,8 @@ fun CipherView.toViewState(
dateTimeFormatter.format(it)
},
passwordHistoryCount = passwordHistory?.count(),
totp = loginValues.totp,
isPremiumUser = isPremiumUser,
totpCodeItemData = totpCodeItemData,
)
}

View file

@ -1,19 +1,14 @@
package com.x8bit.bitwarden.ui.vault.feature.verificationcode
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -23,13 +18,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenCircularCountdownIndicator
import com.x8bit.bitwarden.ui.platform.components.BitwardenIcon
import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -103,7 +98,7 @@ fun VaultVerificationCodeItem(
}
}
CircularIndicator(
BitwardenCircularCountdownIndicator(
timeLeftSeconds = timeLeftSeconds,
periodSeconds = periodSeconds,
)
@ -127,38 +122,6 @@ fun VaultVerificationCodeItem(
}
}
@Composable
private fun CircularIndicator(
timeLeftSeconds: Int,
periodSeconds: Int,
) {
val progressAnimate by animateFloatAsState(
targetValue = timeLeftSeconds.toFloat() / periodSeconds,
animationSpec = tween(
durationMillis = periodSeconds,
delayMillis = 0,
easing = LinearOutSlowInEasing,
),
)
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = { progressAnimate },
modifier = Modifier.size(size = 50.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
)
Text(
text = timeLeftSeconds.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Suppress("MagicNumber")
@Preview(showBackground = true)
@Composable

View file

@ -2388,6 +2388,66 @@ class VaultRepositoryTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `getVerificationCodeFlow for a single cipher should update data state when state changes`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSyncResponse = createMockSyncResponse(number = 1)
coEvery { syncService.sync() } returns mockSyncResponse.asSuccess()
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultDiskSource.replaceVaultData(
userId = MOCK_USER_STATE.activeUserId,
vault = mockSyncResponse,
)
} just runs
every {
settingsDiskSource.storeLastSyncTime(MOCK_USER_STATE.activeUserId, clock.instant())
} just runs
val stateFlow = MutableStateFlow<DataState<VerificationCodeItem?>>(
DataState.Loading,
)
every {
totpCodeManager.getTotpCodeStateFlow(userId = userId, any())
} returns stateFlow
setupDataStateFlow(userId = userId)
vaultRepository.getAuthCodeFlow(createMockCipherView(1).id.toString()).test {
assertEquals(
DataState.Loading,
awaitItem(),
)
stateFlow.tryEmit(DataState.Loaded(createVerificationCodeItem()))
assertEquals(
DataState.Loaded(createVerificationCodeItem()),
awaitItem(),
)
vaultRepository.sync()
assertEquals(
DataState.Pending(createVerificationCodeItem()),
awaitItem(),
)
}
}
@Test
fun `getVerificationCodesFlow should update data state when state changes`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE

View file

@ -36,6 +36,7 @@ import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.every
@ -1011,8 +1012,8 @@ class VaultItemScreenTest : BaseComposeTest() {
passwordHistoryCount = null,
uris = emptyList(),
passwordRevisionDate = null,
totp = null,
isPremiumUser = true,
totpCodeItemData = null,
),
),
)
@ -1100,6 +1101,102 @@ class VaultItemScreenTest : BaseComposeTest() {
}
}
@Test
fun `in login state, the TOTP field should exist based on the state`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
type = DEFAULT_LOGIN.copy(
totpCodeItemData = null,
),
),
)
}
composeTestRule
.onNode(isProgressBar)
.assertDoesNotExist()
composeTestRule
.onNodeWithContentDescription("Copy TOTP")
.assertDoesNotExist()
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
type = DEFAULT_LOGIN.copy(
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
),
),
)
}
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("Copy TOTP")
.assertIsDisplayed()
}
@Test
fun `in login state, TOTP item should be displayed according to state`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE,
)
}
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("Copy TOTP")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
type = DEFAULT_LOGIN.copy(
isPremiumUser = false,
),
),
)
}
composeTestRule
.onNode(isProgressBar)
.assertIsNotDisplayed()
composeTestRule
.onNodeWithContentDescription("Copy TOTP")
.assertIsNotDisplayed()
}
@Test
fun `in login state, on copy totp click should send CopyTotpClick`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE,
)
}
composeTestRule
.onNodeWithContentDescription("Copy TOTP")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyTotpClick)
}
}
@Test
fun `in login state, launch uri button should be displayed according to state`() {
val uriData = VaultItemState.ViewState.Content.ItemType.Login.UriData(
@ -1290,8 +1387,10 @@ class VaultItemScreenTest : BaseComposeTest() {
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE)
mutableStateFlow.update { currentState ->
updateLoginType(currentState) {
copy(totpCodeItemData = null)
}
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@ -1779,8 +1878,13 @@ private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
),
),
passwordRevisionDate = "4/14/83 3:56 PM",
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
isPremiumUser = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
)
private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity =
@ -1821,7 +1925,7 @@ private val EMPTY_LOGIN_TYPE: VaultItemState.ViewState.Content.ItemType.Login =
passwordHistoryCount = null,
uris = emptyList(),
passwordRevisionDate = null,
totp = null,
totpCodeItemData = null,
isPremiumUser = true,
)

View file

@ -11,14 +11,17 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
import com.x8bit.bitwarden.data.platform.repository.model.DataState
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.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent
import com.x8bit.bitwarden.ui.vault.feature.item.util.createLoginContent
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.coEvery
@ -41,6 +44,8 @@ import org.junit.jupiter.api.Test
class VaultItemViewModelTest : BaseViewModelTest() {
private val mutableVaultItemFlow = MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val mutableAuthCodeItemFlow =
MutableStateFlow<DataState<VerificationCodeItem?>>(DataState.Loading)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val clipboardManager: BitwardenClipboardManager = mockk()
@ -48,6 +53,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
every { userStateFlow } returns mutableUserStateFlow
}
private val vaultRepo: VaultRepository = mockk {
every { getAuthCodeFlow(VAULT_ITEM_ID) } returns mutableAuthCodeItemFlow
every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow
}
@ -73,6 +79,11 @@ class VaultItemViewModelTest : BaseViewModelTest() {
every {
vaultRepo.getVaultItemStateFlow(differentVaultItemId)
} returns MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
every {
vaultRepo.getAuthCodeFlow(differentVaultItemId)
} returns MutableStateFlow<DataState<VerificationCodeItem?>>(DataState.Loading)
val state = DEFAULT_STATE.copy(vaultItemId = differentVaultItemId)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
@ -117,9 +128,17 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `ConfirmDeleteClick with DeleteCipherResult Success should should ShowToast and NavigateBack`() =
runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.softDeleteCipher(
@ -147,9 +166,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `ConfirmDeleteClick with DeleteCipherResult Failure should should Show generic error`() =
runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.softDeleteCipher(
@ -176,9 +202,17 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() =
runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.restoreCipher(
@ -206,9 +240,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `ConfirmRestoreClick with RestoreCipherResult Failure should should Show generic error`() =
runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
val viewModel = createViewModel(state = DEFAULT_STATE)
coEvery {
vaultRepo.restoreCipher(
@ -246,9 +287,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
@Test
fun `on EditClick should prompt for master password when required`() = runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem())
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val viewModel = createViewModel(state = loginState)
assertEquals(loginState, viewModel.stateFlow.value)
@ -266,9 +313,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
common = DEFAULT_COMMON.copy(requiresReprompt = false),
)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
@ -290,9 +343,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
common = DEFAULT_COMMON.copy(requiresReprompt = false),
)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val viewModel = createViewModel(state = loginState)
@ -336,9 +396,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick("field"))
@ -348,7 +414,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -358,17 +427,25 @@ class VaultItemViewModelTest : BaseViewModelTest() {
val field = "field"
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false))
}
every { clipboardManager.setText(text = field) } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
viewModel.trySendAction(VaultItemAction.Common.CopyCustomHiddenFieldClick(field))
verify(exactly = 1) {
clipboardManager.setText(text = field)
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -390,9 +467,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(
@ -412,7 +495,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -437,9 +523,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
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(
@ -461,7 +553,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -531,9 +626,17 @@ class VaultItemViewModelTest : BaseViewModelTest() {
@Test
fun `on CheckForBreachClick should process a password`() = runTest {
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val breachCount = 5
coEvery {
@ -562,7 +665,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
coVerify(exactly = 1) {
authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD)
@ -574,9 +680,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick)
@ -586,26 +699,55 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `on CopyPasswordClick should call setText on the CLipboardManager when re-prompt is not required`() {
fun `on CopyPasswordClick should call setText on the ClipboardManager when re-prompt is not required`() {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false))
}
every { clipboardManager.setText(text = DEFAULT_LOGIN_PASSWORD) } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
every { clipboardManager.setText(text = DEFAULT_LOGIN_PASSWORD) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyPasswordClick)
verify(exactly = 1) {
clipboardManager.setText(text = DEFAULT_LOGIN_PASSWORD)
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@Test
fun `on CopyTotpClick should call setText on the ClipboardManager`() {
every { clipboardManager.setText(text = "123456") } just runs
mutableVaultItemFlow.value = DataState.Loaded(
data = createMockCipherView(1),
)
mutableAuthCodeItemFlow.value = DataState.Loaded(
data = createVerificationCodeItem(),
)
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyTotpClick)
verify(exactly = 1) {
clipboardManager.setText(text = "123456")
}
}
@ -624,9 +766,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick)
@ -636,7 +785,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@ -645,17 +797,24 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `on CopyUsernameClick should call setText on ClipboardManager when re-prompt is not required`() {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns createViewState(common = DEFAULT_COMMON.copy(requiresReprompt = false))
}
every { clipboardManager.setText(text = DEFAULT_LOGIN_USERNAME) } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem())
viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUsernameClick)
verify(exactly = 1) {
clipboardManager.setText(text = DEFAULT_LOGIN_USERNAME)
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@ -673,9 +832,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick)
@ -685,7 +851,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@ -695,7 +864,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
.returns(
createViewState(
@ -704,6 +876,8 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.ItemType.Login.PasswordHistoryClick)
@ -714,7 +888,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@ -724,9 +901,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(
@ -740,7 +924,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
@ -753,9 +940,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
val loginState = DEFAULT_STATE.copy(viewState = loginViewState)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns loginViewState
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
} returns loginViewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(
@ -776,7 +970,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = createTotpCodeData(),
)
}
}
}
@ -799,9 +996,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns CARD_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
assertEquals(cardState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
@ -811,7 +1014,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -820,7 +1026,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `on CopyNumberClick should call setText on the ClipboardManager when re-prompt is not required`() {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = false),
type = DEFAULT_CARD_TYPE,
@ -828,12 +1037,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
every { clipboardManager.setText(text = "12345436") } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
verify(exactly = 1) {
clipboardManager.setText(text = "12345436")
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -842,9 +1055,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
runTest {
val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE
every {
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns CARD_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
assertEquals(cardState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
@ -854,7 +1073,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
@ -863,7 +1085,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
fun `on CopySecurityCodeClick should call setText on the ClipboardManager when re-prompt is not required`() {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
} returns createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = false),
type = DEFAULT_CARD_TYPE,
@ -871,12 +1096,16 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
every { clipboardManager.setText(text = "987") } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
verify(exactly = 1) {
clipboardManager.setText(text = "987")
mockCipherView.toViewState(isPremiumUser = true)
mockCipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
}
}
}
@ -906,6 +1135,14 @@ class VaultItemViewModelTest : BaseViewModelTest() {
type = type,
)
private fun createTotpCodeData() =
TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 30,
verificationCode = "123456",
totpCode = "mockTotp-1",
)
companion object {
private const val VAULT_ITEM_ID = "vault_item_id"
private const val DEFAULT_LOGIN_PASSWORD = "password"
@ -951,9 +1188,14 @@ class VaultItemViewModelTest : BaseViewModelTest() {
),
),
passwordRevisionDate = "12/31/69 06:16 PM",
totp = "otpauth://totp/Example:alice@google.com" +
"?secret=JBSWY3DPEHPK3PXP&issuer=Example",
isPremiumUser = true,
totpCodeItemData = TotpCodeItemData(
totpCode = "otpauth://totp/Example:alice@google.com" +
"?secret=JBSWY3DPEHPK3PXP&issuer=Example",
verificationCode = "123456",
timeLeftSeconds = 15,
periodSeconds = 30,
),
)
private val DEFAULT_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.item.util
import com.bitwarden.core.CipherType
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
@ -25,7 +26,15 @@ class CipherViewExtensionsTest {
@Test
fun `toViewState should transform full CipherView into ViewState Login Content with premium`() {
val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
)
assertEquals(
VaultItemState.ViewState.Content(
@ -41,7 +50,15 @@ class CipherViewExtensionsTest {
fun `toViewState should transform full CipherView into ViewState Login Content without premium`() {
val isPremiumUser = false
val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = false)
val viewState = cipherView.toViewState(isPremiumUser = isPremiumUser)
val viewState = cipherView.toViewState(
isPremiumUser = isPremiumUser,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
)
assertEquals(
VaultItemState.ViewState.Content(
@ -55,7 +72,10 @@ class CipherViewExtensionsTest {
@Test
fun `toViewState should transform empty CipherView into ViewState Login Content`() {
val cipherView = createCipherView(type = CipherType.LOGIN, isEmpty = true)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
assertEquals(
VaultItemState.ViewState.Content(
@ -71,7 +91,10 @@ class CipherViewExtensionsTest {
@Test
fun `toViewState should transform full CipherView into ViewState Identity Content`() {
val cipherView = createCipherView(type = CipherType.IDENTITY, isEmpty = false)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
assertEquals(
VaultItemState.ViewState.Content(
@ -85,7 +108,10 @@ class CipherViewExtensionsTest {
@Test
fun `toViewState should transform empty CipherView into ViewState Identity Content`() {
val cipherView = createCipherView(type = CipherType.IDENTITY, isEmpty = true)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
assertEquals(
VaultItemState.ViewState.Content(
@ -109,7 +135,10 @@ class CipherViewExtensionsTest {
lastName = null,
),
)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
assertEquals(
VaultItemState.ViewState.Content(
@ -138,7 +167,10 @@ class CipherViewExtensionsTest {
country = null,
),
)
val result = cipherView.toViewState(isPremiumUser = true)
val result = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
assertEquals(
VaultItemState.ViewState.Content(
@ -170,7 +202,10 @@ class CipherViewExtensionsTest {
@Test
fun `toViewState should transform full CipherView into ViewState Secure Note Content`() {
val cipherView = createCipherView(type = CipherType.SECURE_NOTE, isEmpty = false)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
assertEquals(
VaultItemState.ViewState.Content(
@ -185,7 +220,10 @@ class CipherViewExtensionsTest {
@Test
fun `toViewState should transform empty Secure Note CipherView into ViewState Secure Note Content`() {
val cipherView = createCipherView(type = CipherType.SECURE_NOTE, isEmpty = true)
val viewState = cipherView.toViewState(isPremiumUser = true)
val viewState = cipherView.toViewState(
isPremiumUser = true,
totpCodeItemData = null,
)
val expectedState = VaultItemState.ViewState.Content(
common = createCommonContent(isEmpty = true).copy(currentCipher = cipherView),

View file

@ -10,6 +10,7 @@ import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.bitwarden.core.PasswordHistoryView
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
import java.time.Instant
@ -196,9 +197,14 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
)
},
passwordRevisionDate = "1/1/70 12:16 AM".takeUnless { isEmpty },
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"
.takeUnless { isEmpty },
isPremiumUser = true,
totpCodeItemData = TotpCodeItemData(
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
)
.takeUnless { isEmpty },
)
fun createIdentityContent(