BIT-1207: Fully implement account switcher lock and logout (#362)

This commit is contained in:
Brian Yencho 2023-12-11 13:56:02 -06:00 committed by Álison Fernandes
parent b7578b8f96
commit 1adf58aca8
19 changed files with 588 additions and 43 deletions

View file

@ -49,7 +49,6 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
@ -187,13 +186,11 @@ fun LandingScreen(
onSwitchAccountClick = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.SwitchAccountClick(it)) }
},
onLockAccountClick = {
// TODO: Implement lock functionality (BIT-1207)
showNotYetImplementedToast(context)
onLockAccountClick = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.LockAccountClick(it)) }
},
onLogoutAccountClick = {
// TODO: Implement logout functionality (BIT-1207)
showNotYetImplementedToast(context)
onLogoutAccountClick = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.LogoutAccountClick(it)) }
},
onAddAccountClick = {
// Not available

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
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
@ -25,9 +26,11 @@ private const val KEY_STATE = "state"
/**
* Manages application state for the initial landing screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class LandingViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val environmentRepository: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
@ -75,6 +78,8 @@ class LandingViewModel @Inject constructor(
override fun handleAction(action: LandingAction) {
when (action) {
is LandingAction.LockAccountClick -> handleLockAccountClicked(action)
is LandingAction.LogoutAccountClick -> handleLogoutAccountClicked(action)
is LandingAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
is LandingAction.ConfirmSwitchToMatchingAccountClick -> {
handleConfirmSwitchToMatchingAccountClicked(action)
@ -92,6 +97,14 @@ class LandingViewModel @Inject constructor(
}
}
private fun handleLockAccountClicked(action: LandingAction.LockAccountClick) {
vaultRepository.lockVaultIfNecessary(userId = action.accountSummary.userId)
}
private fun handleLogoutAccountClicked(action: LandingAction.LogoutAccountClick) {
authRepository.logout(userId = action.accountSummary.userId)
}
private fun handleSwitchAccountClicked(action: LandingAction.SwitchAccountClick) {
authRepository.switchAccount(userId = action.accountSummary.userId)
}
@ -247,6 +260,23 @@ sealed class LandingEvent {
* Models actions for the landing screen.
*/
sealed class LandingAction {
/**
* Indicates the user has clicked on the given [accountSummary] information in order to lock
* the associated account's vault.
*/
data class LockAccountClick(
val accountSummary: AccountSummary,
) : LandingAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to log out
* of that account.
*/
data class LogoutAccountClick(
val accountSummary: AccountSummary,
) : LandingAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to switch
* to it.

View file

@ -39,7 +39,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
@ -147,13 +146,11 @@ fun LoginScreen(
onSwitchAccountClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.SwitchAccountClick(it)) }
},
onLockAccountClick = {
// TODO: Implement lock functionality (BIT-1207)
showNotYetImplementedToast(context)
onLockAccountClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.LockAccountClick(it)) }
},
onLogoutAccountClick = {
// TODO: Implement logout functionality (BIT-1207)
showNotYetImplementedToast(context)
onLogoutAccountClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.LogoutAccountClick(it)) }
},
onAddAccountClick = {
// Not available

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
@ -35,6 +36,7 @@ private const val KEY_STATE = "state"
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val vaultRepository: VaultRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
initialState = savedStateHandle[KEY_STATE]
@ -68,6 +70,8 @@ class LoginViewModel @Inject constructor(
override fun handleAction(action: LoginAction) {
when (action) {
is LoginAction.LockAccountClick -> handleLockAccountClicked(action)
is LoginAction.LogoutAccountClick -> handleLogoutAccountClicked(action)
is LoginAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
is LoginAction.CloseButtonClick -> handleCloseButtonClicked()
LoginAction.LoginButtonClick -> handleLoginButtonClicked()
@ -86,6 +90,14 @@ class LoginViewModel @Inject constructor(
}
}
private fun handleLockAccountClicked(action: LoginAction.LockAccountClick) {
vaultRepository.lockVaultIfNecessary(userId = action.accountSummary.userId)
}
private fun handleLogoutAccountClicked(action: LoginAction.LogoutAccountClick) {
authRepository.logout(userId = action.accountSummary.userId)
}
private fun handleSwitchAccountClicked(action: LoginAction.SwitchAccountClick) {
authRepository.switchAccount(userId = action.accountSummary.userId)
}
@ -234,6 +246,23 @@ sealed class LoginEvent {
* Models actions for the login screen.
*/
sealed class LoginAction {
/**
* Indicates the user has clicked on the given [accountSummary] information in order to lock
* the associated account's vault.
*/
data class LockAccountClick(
val accountSummary: AccountSummary,
) : LoginAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to log out
* of that account.
*/
data class LogoutAccountClick(
val accountSummary: AccountSummary,
) : LoginAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to switch
* to it.

View file

@ -32,7 +32,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
@ -197,13 +196,11 @@ fun VaultUnlockScreen(
onSwitchAccountClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(it)) }
},
onLockAccountClick = {
// TODO: Implement lock functionality (BIT-1207)
showNotYetImplementedToast(context)
onLockAccountClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.LockAccountClick(it)) }
},
onLogoutAccountClick = {
// TODO: Implement logout functionality (BIT-1207)
showNotYetImplementedToast(context)
onLogoutAccountClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.LogoutAccountClick(it)) }
},
onAddAccountClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.AddAccountClick) }

View file

@ -31,6 +31,7 @@ private const val KEY_STATE = "state"
/**
* Manages application state for the initial vault unlock screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class VaultUnlockViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
@ -79,6 +80,8 @@ class VaultUnlockViewModel @Inject constructor(
VaultUnlockAction.DismissDialog -> handleDismissDialog()
VaultUnlockAction.ConfirmLogoutClick -> handleConfirmLogoutClick()
is VaultUnlockAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is VaultUnlockAction.LockAccountClick -> handleLockAccountClick(action)
is VaultUnlockAction.LogoutAccountClick -> handleLogoutAccountClick(action)
is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action)
VaultUnlockAction.UnlockClick -> handleUnlockClick()
is VaultUnlockAction.Internal.ReceiveVaultUnlockResult -> {
@ -109,6 +112,14 @@ class VaultUnlockViewModel @Inject constructor(
}
}
private fun handleLockAccountClick(action: VaultUnlockAction.LockAccountClick) {
vaultRepo.lockVaultIfNecessary(userId = action.accountSummary.userId)
}
private fun handleLogoutAccountClick(action: VaultUnlockAction.LogoutAccountClick) {
authRepository.logout(userId = action.accountSummary.userId)
}
private fun handleSwitchAccountClick(action: VaultUnlockAction.SwitchAccountClick) {
authRepository.switchAccount(userId = action.accountSummary.userId)
}
@ -253,6 +264,22 @@ sealed class VaultUnlockAction {
val passwordInput: String,
) : VaultUnlockAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to lock
* the associated account's vault.
*/
data class LockAccountClick(
val accountSummary: AccountSummary,
) : VaultUnlockAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to log out
* of that account.
*/
data class LogoutAccountClick(
val accountSummary: AccountSummary,
) : VaultUnlockAction()
/**
* The user has clicked the an account to switch too.
*/

View file

@ -108,8 +108,14 @@ fun BitwardenAccountSwitcher(
LockOrLogoutDialog(
accountSummary = requireNotNull(lockOrLogoutAccount),
onDismissRequest = { lockOrLogoutAccount = null },
onLockAccountClick = onLockAccountClick,
onLogoutAccountClick = onLogoutAccountClick,
onLockAccountClick = {
onLockAccountClick(it)
lockOrLogoutAccount = null
},
onLogoutAccountClick = {
onLogoutAccountClick(it)
lockOrLogoutAccount = null
},
)
}
@ -306,12 +312,14 @@ private fun LockOrLogoutDialog(
title = "${accountSummary.email}\n${accountSummary.environmentLabel}",
onDismissRequest = onDismissRequest,
selectionItems = {
BitwardenBasicDialogRow(
text = stringResource(id = R.string.lock),
onClick = {
onLockAccountClick(accountSummary)
},
)
if (accountSummary.isVaultUnlocked) {
BitwardenBasicDialogRow(
text = stringResource(id = R.string.lock),
onClick = {
onLockAccountClick(accountSummary)
},
)
}
BitwardenBasicDialogRow(
text = stringResource(id = R.string.log_out),
onClick = {

View file

@ -28,7 +28,6 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
@ -87,6 +86,12 @@ fun VaultScreen(
searchIconClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.SearchIconClick) }
},
accountLockClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.LockAccountClick(it)) }
},
accountLogoutClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.LogoutAccountClick(it)) }
},
accountSwitchClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAction.SwitchAccountClick(it)) }
},
@ -128,6 +133,8 @@ private fun VaultScreenScaffold(
state: VaultState,
addItemClickAction: () -> Unit,
searchIconClickAction: () -> Unit,
accountLockClickAction: (AccountSummary) -> Unit,
accountLogoutClickAction: (AccountSummary) -> Unit,
accountSwitchClickAction: (AccountSummary) -> Unit,
addAccountClickAction: () -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
@ -219,14 +226,8 @@ private fun VaultScreenScaffold(
isVisible = accountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(),
onSwitchAccountClick = accountSwitchClickAction,
onLockAccountClick = {
// TODO: Implement lock functionality (BIT-1207)
showNotYetImplementedToast(context)
},
onLogoutAccountClick = {
// TODO: Implement logout functionality (BIT-1207)
showNotYetImplementedToast(context)
},
onLockAccountClick = accountLockClickAction,
onLogoutAccountClick = accountLogoutClickAction,
onAddAccountClick = addAccountClickAction,
onDismissRequest = { updateAccountMenuVisibility(false) },
topAppBarScrollBehavior = scrollBehavior,

View file

@ -38,7 +38,7 @@ import javax.inject.Inject
@HiltViewModel
class VaultViewModel @Inject constructor(
private val authRepository: AuthRepository,
vaultRepository: VaultRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
@ -87,6 +87,8 @@ class VaultViewModel @Inject constructor(
is VaultAction.IdentityGroupClick -> handleIdentityClick()
is VaultAction.LoginGroupClick -> handleLoginClick()
is VaultAction.SearchIconClick -> handleSearchIconClick()
is VaultAction.LockAccountClick -> handleLockAccountClick(action)
is VaultAction.LogoutAccountClick -> handleLogoutAccountClick(action)
is VaultAction.SwitchAccountClick -> handleSwitchAccountClick(action)
is VaultAction.AddAccountClick -> handleAddAccountClick()
is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick()
@ -128,6 +130,14 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateToVaultSearchScreen)
}
private fun handleLockAccountClick(action: VaultAction.LockAccountClick) {
vaultRepository.lockVaultIfNecessary(userId = action.accountSummary.userId)
}
private fun handleLogoutAccountClick(action: VaultAction.LogoutAccountClick) {
authRepository.logout(userId = action.accountSummary.userId)
}
private fun handleSwitchAccountClick(action: VaultAction.SwitchAccountClick) {
val isSwitchingAccounts =
when (authRepository.switchAccount(userId = action.accountSummary.userId)) {
@ -461,6 +471,22 @@ sealed class VaultAction {
*/
data object SearchIconClick : VaultAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to lock
* the associated account's vault.
*/
data class LockAccountClick(
val accountSummary: AccountSummary,
) : VaultAction()
/**
* Indicates the user has clicked on the given [accountSummary] information in order to log out
* of that account.
*/
data class LogoutAccountClick(
val accountSummary: AccountSummary,
) : VaultAction()
/**
* User clicked an account in the account switcher.
*/

View file

@ -22,10 +22,15 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountClick
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountLongClick
import com.x8bit.bitwarden.ui.util.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -114,6 +119,60 @@ class LandingScreenTest : BaseComposeTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `account long click in the account switcher should show the lock-or-logout dialog and close the switcher`() {
// Show the account switcher
val accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY)
mutableStateFlow.update {
it.copy(accountSummaries = accountSummaries)
}
composeTestRule.performAccountIconClick()
composeTestRule.assertNoDialogExists()
composeTestRule.performAccountLongClick(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
composeTestRule.assertLockOrLogoutDialogIsDisplayed(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
}
@Suppress("MaxLineLength")
@Test
fun `lock button click in the lock-or-logout dialog should send LockAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
val accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY)
mutableStateFlow.update {
it.copy(accountSummaries = accountSummaries)
}
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLockAccountClick()
verify { viewModel.trySendAction(LandingAction.LockAccountClick(ACTIVE_ACCOUNT_SUMMARY)) }
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `logout button click in the lock-or-logout dialog should send LogoutAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
val accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY)
mutableStateFlow.update {
it.copy(accountSummaries = accountSummaries)
}
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLogoutAccountClick()
verify { viewModel.trySendAction(LandingAction.LogoutAccountClick(ACTIVE_ACCOUNT_SUMMARY)) }
composeTestRule.assertNoDialogExists()
}
@Test
fun `continue button should be enabled or disabled according to the state`() {
composeTestRule.onNodeWithText("Continue").assertIsEnabled()

View file

@ -7,13 +7,16 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummary
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@ -22,7 +25,12 @@ import org.junit.jupiter.api.Test
class LandingViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk(relaxed = true)
private val authRepository: AuthRepository = mockk(relaxed = true) {
every { logout(any()) } just runs
}
private val vaultRepository: VaultRepository = mockk(relaxed = true) {
every { lockVaultIfNecessary(any()) } just runs
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@Test
@ -88,6 +96,32 @@ class LandingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `LockAccountClick should call lockVaultIfNecessary for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(LandingAction.LockAccountClick(accountSummary))
verify { vaultRepository.lockVaultIfNecessary(userId = accountUserId) }
}
@Test
fun `LogoutAccountClick should call logout for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(LandingAction.LogoutAccountClick(accountSummary))
verify { authRepository.logout(userId = accountUserId) }
}
@Test
fun `SwitchAccountClick should call switchAccount for the given account`() {
val matchingAccountUserId = "matchingAccountUserId"
@ -314,6 +348,7 @@ class LandingViewModelTest : BaseViewModelTest() {
every { rememberedEmailAddress } returns rememberedEmail
every { userStateFlow } returns MutableStateFlow(userState)
},
vaultRepository = vaultRepository,
environmentRepository = fakeEnvironmentRepository,
savedStateHandle = savedStateHandle,
)

View file

@ -18,10 +18,15 @@ import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountClick
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountLongClick
import com.x8bit.bitwarden.ui.util.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -103,6 +108,60 @@ class LoginScreenTest : BaseComposeTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `account long click in the account switcher should show the lock-or-logout dialog and close the switcher`() {
// Show the account switcher
val accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY)
mutableStateFlow.update {
it.copy(accountSummaries = accountSummaries)
}
composeTestRule.performAccountIconClick()
composeTestRule.assertNoDialogExists()
composeTestRule.performAccountLongClick(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
composeTestRule.assertLockOrLogoutDialogIsDisplayed(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
}
@Suppress("MaxLineLength")
@Test
fun `lock button click in the lock-or-logout dialog should send LockAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
val accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY)
mutableStateFlow.update {
it.copy(accountSummaries = accountSummaries)
}
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLockAccountClick()
verify { viewModel.trySendAction(LoginAction.LockAccountClick(ACTIVE_ACCOUNT_SUMMARY)) }
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `logout button click in the lock-or-logout dialog should send LogoutAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
val accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY)
mutableStateFlow.update {
it.copy(accountSummaries = accountSummaries)
}
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLogoutAccountClick()
verify { viewModel.trySendAction(LoginAction.LogoutAccountClick(ACTIVE_ACCOUNT_SUMMARY)) }
composeTestRule.assertNoDialogExists()
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()

View file

@ -12,17 +12,22 @@ import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@ -43,6 +48,10 @@ class LoginViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk(relaxed = true) {
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { userStateFlow } returns mutableUserStateFlow
every { logout(any()) } just runs
}
private val vaultRepository: VaultRepository = mockk(relaxed = true) {
every { lockVaultIfNecessary(any()) } just runs
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@ -140,6 +149,45 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `LockAccountClick should call lockVaultIfNecessary for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(LoginAction.LockAccountClick(accountSummary))
verify { vaultRepository.lockVaultIfNecessary(userId = accountUserId) }
}
@Test
fun `LogoutAccountClick should call logout for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(LoginAction.LogoutAccountClick(accountSummary))
verify { authRepository.logout(userId = accountUserId) }
}
@Test
fun `SwitchAccountClick should call switchAccount for the given account`() {
val matchingAccountUserId = "matchingAccountUserId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns matchingAccountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(LoginAction.SwitchAccountClick(accountSummary))
verify { authRepository.switchAccount(userId = matchingAccountUserId) }
}
@Test
fun `CloseButtonClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@ -316,6 +364,7 @@ class LoginViewModelTest : BaseViewModelTest() {
LoginViewModel(
authRepository = authRepository,
environmentRepository = fakeEnvironmentRepository,
vaultRepository = vaultRepository,
savedStateHandle = savedStateHandle,
)

View file

@ -15,11 +15,16 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
import com.x8bit.bitwarden.ui.util.performAccountClick
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountLongClick
import com.x8bit.bitwarden.ui.util.performAddAccountClick
import com.x8bit.bitwarden.ui.util.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -92,6 +97,52 @@ class VaultUnlockScreenTest : BaseComposeTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `account long click in the account switcher should show the lock-or-logout dialog and close the switcher`() {
// Show the account switcher
composeTestRule.performAccountIconClick()
composeTestRule.assertNoDialogExists()
composeTestRule.performAccountLongClick(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
composeTestRule.assertLockOrLogoutDialogIsDisplayed(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
}
@Suppress("MaxLineLength")
@Test
fun `lock button click in the lock-or-logout dialog should send LockAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLockAccountClick()
verify {
viewModel.trySendAction(VaultUnlockAction.LockAccountClick(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `logout button click in the lock-or-logout dialog should send LogoutAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLogoutAccountClick()
verify {
viewModel.trySendAction(VaultUnlockAction.LogoutAccountClick(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `logout click in the overflow menu should show the logout confirmation dialog`() {
// Confirm neither the popup nor the dialog are showing

View file

@ -36,9 +36,12 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
every { specialCircumstance } returns null
every { specialCircumstance = any() } just runs
every { logout() } just runs
every { logout(any()) } just runs
every { switchAccount(any()) } returns SwitchAccountResult.AccountSwitched
}
private val vaultRepository = mockk<VaultRepository>()
private val vaultRepository: VaultRepository = mockk(relaxed = true) {
every { lockVaultIfNecessary(any()) } just runs
}
@Test
fun `initial state should be correct when not set`() {
@ -167,6 +170,32 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `on LockAccountClick should call lockVaultIfNecessary for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(VaultUnlockAction.LockAccountClick(accountSummary))
verify { vaultRepository.lockVaultIfNecessary(userId = accountUserId) }
}
@Test
fun `on LogoutAccountClick should call logout for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(VaultUnlockAction.LogoutAccountClick(accountSummary))
verify { authRepository.logout(userId = accountUserId) }
}
@Test
fun `on SwitchAccountClick should switch to the given account`() = runTest {
val viewModel = createViewModel()

View file

@ -1,10 +1,16 @@
package com.x8bit.bitwarden.ui.util
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
private const val ACCOUNT = "Account"
@ -43,6 +49,35 @@ fun ComposeContentTestRule.assertSwitcherIsNotDisplayed(
this.onNodeWithText(ADD_ACCOUNT).assertDoesNotExist()
}
/**
* Asserts the "lock or logout" dialog is currently displayed with information from the given
* [accountSummary].
*/
fun ComposeContentTestRule.assertLockOrLogoutDialogIsDisplayed(
accountSummary: AccountSummary,
) {
this.waitForIdle()
this
.onNode(isDialog())
.assertIsDisplayed()
this
.onAllNodesWithText("Lock")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText("Log out")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText(accountSummary.email, substring = true)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
this
.onAllNodesWithText(accountSummary.environmentLabel, substring = true)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Clicks on the given [accountSummary] in the account switcher.
*/
@ -52,6 +87,37 @@ fun ComposeContentTestRule.performAccountClick(
this.onNodeWithText(accountSummary.email).performClick()
}
/**
* Long clicks on the given [accountSummary] in the account switcher.
*/
fun ComposeContentTestRule.performAccountLongClick(
accountSummary: AccountSummary,
) {
this.onNodeWithText(accountSummary.email).performTouchInput {
this.longClick()
}
}
/**
* Clicks the "Lock" button in the "lock or logout" dialog.
*/
fun ComposeContentTestRule.performLockAccountClick() {
this
.onAllNodesWithText("Lock")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
}
/**
* Clicks the "Lock" button in the "lock or logout" dialog.
*/
fun ComposeContentTestRule.performLogoutAccountClick() {
this
.onAllNodesWithText("Log out")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
}
/**
* Opens the account switcher.
*

View file

@ -8,6 +8,7 @@ import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
@ -28,6 +29,15 @@ val isProgressBar: SemanticsMatcher
?: false
}
/**
* Asserts that no dialog currently exists.
*/
fun ComposeContentTestRule.assertNoDialogExists() {
this
.onNode(isDialog())
.assertDoesNotExist()
}
/**
* A helper that asserts that the node does not exist in the scrollable list.
*/

View file

@ -12,11 +12,16 @@ import androidx.compose.ui.test.performScrollToNode
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertSwitcherIsDisplayed
import com.x8bit.bitwarden.ui.util.assertSwitcherIsNotDisplayed
import com.x8bit.bitwarden.ui.util.performAccountClick
import com.x8bit.bitwarden.ui.util.performAccountIconClick
import com.x8bit.bitwarden.ui.util.performAccountLongClick
import com.x8bit.bitwarden.ui.util.performAddAccountClick
import com.x8bit.bitwarden.ui.util.performLockAccountClick
import com.x8bit.bitwarden.ui.util.performLogoutAccountClick
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.every
import io.mockk.mockk
@ -109,6 +114,48 @@ class VaultScreenTest : BaseComposeTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `account long click in the account switcher should show the lock-or-logout dialog and close the switcher`() {
// Show the account switcher
composeTestRule.performAccountIconClick()
composeTestRule.assertNoDialogExists()
composeTestRule.performAccountLongClick(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
composeTestRule.assertLockOrLogoutDialogIsDisplayed(
accountSummary = ACTIVE_ACCOUNT_SUMMARY,
)
}
@Suppress("MaxLineLength")
@Test
fun `lock button click in the lock-or-logout dialog should send LockAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLockAccountClick()
verify { viewModel.trySendAction(VaultAction.LockAccountClick(ACTIVE_ACCOUNT_SUMMARY)) }
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `logout button click in the lock-or-logout dialog should send LogoutAccountClick action and close the dialog`() {
// Show the lock-or-logout dialog
composeTestRule.performAccountIconClick()
composeTestRule.performAccountLongClick(ACTIVE_ACCOUNT_SUMMARY)
composeTestRule.performLogoutAccountClick()
verify { viewModel.trySendAction(VaultAction.LogoutAccountClick(ACTIVE_ACCOUNT_SUMMARY)) }
composeTestRule.assertNoDialogExists()
}
@Test
fun `search icon click should send SearchIconClick action`() {
mutableStateFlow.update { it.copy(viewState = VaultState.ViewState.NoItems) }

View file

@ -40,6 +40,7 @@ class VaultViewModelTest : BaseViewModelTest() {
every { userStateFlow } returns mutableUserStateFlow
every { specialCircumstance } returns null
every { specialCircumstance = any() } just runs
every { logout(any()) } just runs
every { switchAccount(any()) } answers { switchAccountResult }
}
@ -47,6 +48,7 @@ class VaultViewModelTest : BaseViewModelTest() {
mockk {
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { sync() } returns Unit
every { lockVaultIfNecessary(any()) } just runs
}
@Test
@ -144,6 +146,32 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `on LockAccountClick should call lockVaultIfNecessary for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.LockAccountClick(accountSummary))
verify { vaultRepository.lockVaultIfNecessary(userId = accountUserId) }
}
@Test
fun `on LogoutAccountClick should call logout for the given account`() {
val accountUserId = "userId"
val accountSummary = mockk<AccountSummary> {
every { userId } returns accountUserId
}
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.LogoutAccountClick(accountSummary))
verify { authRepository.logout(userId = accountUserId) }
}
@Suppress("MaxLineLength")
@Test
fun `on SwitchAccountClick when result is NoChange should try to switch to the given account and set isSwitchingAccounts to false`() =