BIT-999: Add UI for Vault Unlock screen (#243)

This commit is contained in:
David Perez 2023-11-14 08:53:05 -06:00 committed by Álison Fernandes
parent 2f525513a2
commit 00e249dab2
8 changed files with 892 additions and 22 deletions

View file

@ -12,19 +12,18 @@ 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.platform.repository.model.Environment
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.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.net.URI
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -282,23 +281,3 @@ sealed class LoginAction {
) : Internal()
}
}
/**
* Returns the [Environment.label] for non-custom values. Otherwise returns the host of the
* custom base URL.
*/
private val Environment.labelOrBaseUrlHost: Text
get() = when (this) {
is Environment.Us -> this.label
is Environment.Eu -> this.label
is Environment.SelfHosted -> {
// Grab the domain
// Ex:
// - "https://www.abc.com/path-1/path-1" -> "www.abc.com"
URI
.create(this.environmentUrlData.base)
.host
.orEmpty()
.asText()
}
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
private const val VAULT_UNLOCK: String = "vault_unlock"
/**
* Navigate to the Vault Unlock screen.
*/
fun NavController.navigateToVaultUnlock(
navOptions: NavOptions? = null,
) {
navigate(VAULT_UNLOCK, navOptions)
}
/**
* Add the Vault Unlock screen to the nav graph.
*/
fun NavGraphBuilder.vaultUnlockDestinations() {
composable(
route = VAULT_UNLOCK,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
VaultUnlockScreen()
}
}

View file

@ -0,0 +1,187 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import kotlinx.collections.immutable.toImmutableList
/**
* The top level composable for the Vault Unlock screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun VaultUnlockScreen(
viewModel: VaultUnlockViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultUnlockEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
}
VaultUnlockEvent.NavigateToLoginScreen -> {
// TODO: Handle adding accounts (BIT-853)
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
}
}
}
var accountMenuVisible by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { !accountMenuVisible },
)
when (val dialog = state.dialog) {
is VaultUnlockState.VaultUnlockDialog.Error -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = dialog.message,
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.DismissDialog) }
},
)
VaultUnlockState.VaultUnlockDialog.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(R.string.loading.asText()),
)
null -> Unit
}
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.verify_master_password),
scrollBehavior = scrollBehavior,
navigationIcon = null,
actions = {
BitwardenAccountActionItem(
initials = state.initials,
color = state.avatarColor,
onClick = { accountMenuVisible = !accountMenuVisible },
)
BitwardenOverflowActionItem()
},
)
},
) { innerPadding ->
Box {
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = state.passwordInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(it)) }
},
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.vault_locked_master_password),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(
id = R.string.logged_in_as_on,
state.email,
state.environmentUrl(),
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.unlock),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
},
isEnabled = state.passwordInput.isNotEmpty(),
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
BitwardenAccountSwitcher(
isVisible = accountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(),
onAccountSummaryClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(it)) }
},
onAddAccountClick = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.AddAccountClick) }
},
onDismissRequest = { accountMenuVisible = false },
topAppBarScrollBehavior = scrollBehavior,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
)
}
}
}

View file

@ -0,0 +1,236 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
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.hexToColor
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages application state for the initial vault unlock screen.
*/
@HiltViewModel
class VaultUnlockViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val vaultRepo: VaultRepository,
environmentRepo: EnvironmentRepository,
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
initialState = savedStateHandle[KEY_STATE] ?: VaultUnlockState(
accountSummaries = emptyList(),
avatarColorString = "0000FF",
initials = "BW",
email = "bit@bitwarden.com",
dialog = null,
environmentUrl = environmentRepo.environment.labelOrBaseUrlHost,
passwordInput = "",
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
environmentRepo
.environmentStateFlow
.onEach { environment ->
mutableStateFlow.update {
it.copy(environmentUrl = environment.labelOrBaseUrlHost)
}
}
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultUnlockAction) {
when (action) {
VaultUnlockAction.AddAccountClick -> handleAddAccountClick()
VaultUnlockAction.DismissDialog -> handleDismissDialog()
is VaultUnlockAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action)
VaultUnlockAction.UnlockClick -> handleUnlockClick()
is VaultUnlockAction.Internal.ReceiveVaultUnlockResult -> {
handleReceiveVaultUnlockResult(action)
}
}
}
private fun handleAddAccountClick() {
sendEvent(VaultUnlockEvent.NavigateToLoginScreen)
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handlePasswordInputChanged(action: VaultUnlockAction.PasswordInputChanged) {
mutableStateFlow.update {
it.copy(passwordInput = action.passwordInput)
}
}
private fun handleSwitchAccountClick(action: VaultUnlockAction.SwitchAccountClick) {
// TODO: Handle switching accounts (BIT-853)
sendEvent(VaultUnlockEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleUnlockClick() {
mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) }
viewModelScope.launch {
val vaultUnlockResult = vaultRepo.unlockVaultAndSync(
mutableStateFlow.value.passwordInput,
)
sendAction(VaultUnlockAction.Internal.ReceiveVaultUnlockResult(vaultUnlockResult))
}
}
private fun handleReceiveVaultUnlockResult(
action: VaultUnlockAction.Internal.ReceiveVaultUnlockResult,
) {
when (action.vaultUnlockResult) {
VaultUnlockResult.AuthenticationError -> {
mutableStateFlow.update {
it.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.invalid_master_password.asText(),
),
)
}
}
VaultUnlockResult.GenericError,
VaultUnlockResult.InvalidStateError,
-> {
mutableStateFlow.update {
it.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.generic_error_message.asText(),
),
)
}
}
VaultUnlockResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
// Don't do anything, we'll navigate to the right place.
}
}
}
}
/**
* Models state of the vault unlock screen.
*/
@Parcelize
data class VaultUnlockState(
val accountSummaries: List<AccountSummary>,
private val avatarColorString: String,
val initials: String,
val email: String,
val environmentUrl: Text,
val dialog: VaultUnlockDialog?,
val passwordInput: String,
) : Parcelable {
/**
* The [Color] of the avatar.
*/
val avatarColor: Color get() = avatarColorString.hexToColor()
/**
* Represents the various dialogs the vault unlock screen can display.
*/
sealed class VaultUnlockDialog : Parcelable {
/**
* Represents an error dialog.
*/
@Parcelize
data class Error(
val message: Text,
) : VaultUnlockDialog()
/**
* Represents a loading state dialog.
*/
@Parcelize
data object Loading : VaultUnlockDialog()
}
}
/**
* Models events for the vault unlock screen.
*/
sealed class VaultUnlockEvent {
/**
* Navigates to the login flow.
*/
data object NavigateToLoginScreen : VaultUnlockEvent()
/**
* Displays a toast to the user.
*/
data class ShowToast(
val text: Text,
) : VaultUnlockEvent()
}
/**
* Models actions for the vault unlock screen.
*/
sealed class VaultUnlockAction {
/**
* The user has clicked the add account button.
*/
data object AddAccountClick : VaultUnlockAction()
/**
* The user dismissed the currently displayed dialog.
*/
data object DismissDialog : VaultUnlockAction()
/**
* The user has modified the password input.
*/
data class PasswordInputChanged(
val passwordInput: String,
) : VaultUnlockAction()
/**
* The user has clicked the an account to switch too.
*/
data class SwitchAccountClick(
val accountSummary: AccountSummary,
) : VaultUnlockAction()
/**
* The user has clicked the unlock button.
*/
data object UnlockClick : VaultUnlockAction()
/**
* Models actions that the [VaultUnlockViewModel] itself might send.
*/
sealed class Internal : VaultUnlockAction() {
/**
* Indicates a vault unlock result has been received.
*/
data class ReceiveVaultUnlockResult(
val vaultUnlockResult: VaultUnlockResult,
) : Internal()
}
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import java.net.URI
/**
* Returns the [Environment.label] for non-custom values. Otherwise returns the host of the
* custom base URL.
*/
val Environment.labelOrBaseUrlHost: Text
get() = when (this) {
is Environment.Us -> this.label
is Environment.Eu -> this.label
is Environment.SelfHosted -> {
// Grab the domain
// Ex:
// - "https://www.abc.com/path-1/path-1" -> "www.abc.com"
URI
.create(this.environmentUrlData.base)
.host
.orEmpty()
.asText()
}
}

View file

@ -0,0 +1,163 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class VaultUnlockScreenTest : BaseComposeTest() {
private val mutableEventFlow = MutableSharedFlow<VaultUnlockEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultUnlockViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
VaultUnlockScreen(
viewModel = viewModel,
)
}
}
@Test
fun `account icon click should show the account switcher`() {
composeTestRule.onNodeWithText("active@bitwarden.com").assertDoesNotExist()
composeTestRule.onNodeWithText("locked@bitwarden.com").assertDoesNotExist()
composeTestRule.onNodeWithText("Add account").assertDoesNotExist()
composeTestRule.onNodeWithText("AU").performClick()
composeTestRule.onNodeWithText("active@bitwarden.com").assertIsDisplayed()
composeTestRule.onNodeWithText("locked@bitwarden.com").assertIsDisplayed()
composeTestRule.onNodeWithText("Add account").assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `account click in the account switcher should send AccountSwitchClick and close switcher`() {
// Open the Account Switcher
composeTestRule.onNodeWithText("AU").performClick()
composeTestRule.onNodeWithText("locked@bitwarden.com").performClick()
verify {
viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(LOCKED_ACCOUNT_SUMMARY))
}
composeTestRule.onNodeWithText("locked@bitwarden.com").assertDoesNotExist()
}
@Suppress("MaxLineLength")
@Test
fun `add account click in the account switcher should send AddAccountClick and close switcher`() {
// Open the Account Switcher
composeTestRule.onNodeWithText("AU").performClick()
composeTestRule.onNodeWithText("Add account").performClick()
verify { viewModel.trySendAction(VaultUnlockAction.AddAccountClick) }
composeTestRule.onNodeWithText("Add account").assertDoesNotExist()
}
@Test
fun `email state change should update logged in as text`() {
val newEmail = "david@bitwarden.com"
val textBeforeUpdate = "Logged in as ${DEFAULT_STATE.email} on $DEFAULT_ENVIRONMENT_URL."
val textAfterUpdate = "Logged in as $newEmail on $DEFAULT_ENVIRONMENT_URL."
composeTestRule.onNodeWithText(textBeforeUpdate).assertExists()
composeTestRule.onNodeWithText(textAfterUpdate).assertDoesNotExist()
mutableStateFlow.update { it.copy(email = newEmail) }
composeTestRule.onNodeWithText(textBeforeUpdate).assertDoesNotExist()
composeTestRule.onNodeWithText(textAfterUpdate).assertExists()
}
@Test
fun `environment url state change should update logged in as text`() {
val newEnvironmentUrl = "eu.bitwarden.com"
val textBeforeUpdate = "Logged in as ${DEFAULT_STATE.email} on $DEFAULT_ENVIRONMENT_URL."
val textAfterUpdate = "Logged in as ${DEFAULT_STATE.email} on $newEnvironmentUrl."
composeTestRule.onNodeWithText(textBeforeUpdate).assertExists()
composeTestRule.onNodeWithText(textAfterUpdate).assertDoesNotExist()
mutableStateFlow.update { it.copy(environmentUrl = newEnvironmentUrl.asText()) }
composeTestRule.onNodeWithText(textBeforeUpdate).assertDoesNotExist()
composeTestRule.onNodeWithText(textAfterUpdate).assertExists()
}
@Test
fun `password input state change should update unlock button enabled`() {
composeTestRule.onNodeWithText("Unlock").performScrollTo().assertIsNotEnabled()
mutableStateFlow.update { it.copy(passwordInput = "a") }
composeTestRule.onNodeWithText("Unlock").performScrollTo().assertIsEnabled()
}
@Test
fun `unlock click should send UnlockClick action`() {
mutableStateFlow.update { it.copy(passwordInput = "abdc1234") }
composeTestRule
.onNodeWithText("Unlock")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
}
@Test
fun `master password change should send PasswordInputChanged action`() {
val input = "abcd1234"
composeTestRule
.onNodeWithText("Master password")
.performScrollTo()
.performTextInput(input)
verify {
viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(input))
}
}
}
private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com"
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
status = AccountSummary.Status.ACTIVE,
)
private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
userId = "lockedUserId",
name = "Locked User",
email = "locked@bitwarden.com",
avatarColorHex = "#00aaaa",
status = AccountSummary.Status.LOCKED,
)
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
accountSummaries = persistentListOf(
ACTIVE_ACCOUNT_SUMMARY,
LOCKED_ACCOUNT_SUMMARY,
),
avatarColorString = "0000FF",
dialog = null,
email = "bit@bitwarden.com",
environmentUrl = DEFAULT_ENVIRONMENT_URL.asText(),
initials = "AU",
passwordInput = "",
)

View file

@ -0,0 +1,207 @@
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
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.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultUnlockViewModelTest : BaseViewModelTest() {
private val environmentRepository = FakeEnvironmentRepository()
private val vaultRepository = mockk<VaultRepository>()
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(
initials = "WB",
avatarColorString = "00FF00",
)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `environment url should update when environment repo emits an update`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
environmentRepository.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.bitwarden.eu"),
)
assertEquals(
DEFAULT_STATE.copy(environmentUrl = "vault.bitwarden.eu".asText()),
viewModel.stateFlow.value,
)
}
@Test
fun `on AddAccountClick should emit NavigateToLoginScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultUnlockAction.AddAccountClick)
assertEquals(VaultUnlockEvent.NavigateToLoginScreen, awaitItem())
}
}
@Test
fun `on DismissDialog should clear the dialog state`() = runTest {
val initialState = DEFAULT_STATE.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading)
val viewModel = createViewModel(state = initialState)
viewModel.trySendAction(VaultUnlockAction.DismissDialog)
assertEquals(
initialState.copy(dialog = null),
viewModel.stateFlow.value,
)
}
@Test
fun `on PasswordInputChanged should update the password input state`() = runTest {
val viewModel = createViewModel()
val password = "abcd1234"
viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(passwordInput = password))
assertEquals(
DEFAULT_STATE.copy(passwordInput = password),
viewModel.stateFlow.value,
)
}
@Test
fun `on SwitchAccountClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
val accountSummary = mockk<AccountSummary> {
every { status } returns AccountSummary.Status.ACTIVE
}
viewModel.eventFlow.test {
viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(accountSummary))
assertEquals(VaultUnlockEvent.ShowToast("Not yet implemented.".asText()), awaitItem())
}
}
@Test
fun `on UnlockClick should display error dialog on AuthenticationError`() = runTest {
val password = "abcd1234"
val initialState = DEFAULT_STATE.copy(passwordInput = password)
val viewModel = createViewModel(state = initialState)
coEvery {
vaultRepository.unlockVaultAndSync(password)
} returns VaultUnlockResult.AuthenticationError
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals(
initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.invalid_master_password.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify {
vaultRepository.unlockVaultAndSync(password)
}
}
@Test
fun `on UnlockClick should display error dialog on GenericError`() = runTest {
val password = "abcd1234"
val initialState = DEFAULT_STATE.copy(passwordInput = password)
val viewModel = createViewModel(state = initialState)
coEvery {
vaultRepository.unlockVaultAndSync(password)
} returns VaultUnlockResult.GenericError
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals(
initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify {
vaultRepository.unlockVaultAndSync(password)
}
}
@Test
fun `on UnlockClick should display error dialog on InvalidStateError`() = runTest {
val password = "abcd1234"
val initialState = DEFAULT_STATE.copy(passwordInput = password)
val viewModel = createViewModel(state = initialState)
coEvery {
vaultRepository.unlockVaultAndSync(password)
} returns VaultUnlockResult.InvalidStateError
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals(
initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
coVerify {
vaultRepository.unlockVaultAndSync(password)
}
}
@Test
fun `on UnlockClick should display clear dialog on success`() = runTest {
val password = "abcd1234"
val initialState = DEFAULT_STATE.copy(passwordInput = password)
val viewModel = createViewModel(state = initialState)
coEvery {
vaultRepository.unlockVaultAndSync(password)
} returns VaultUnlockResult.Success
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
assertEquals(
initialState.copy(dialog = null),
viewModel.stateFlow.value,
)
coVerify {
vaultRepository.unlockVaultAndSync(password)
}
}
private fun createViewModel(
state: VaultUnlockState? = DEFAULT_STATE,
environmentRepo: EnvironmentRepository = environmentRepository,
vaultRepo: VaultRepository = vaultRepository,
): VaultUnlockViewModel = VaultUnlockViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
vaultRepo = vaultRepo,
environmentRepo = environmentRepo,
)
}
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
accountSummaries = emptyList(),
avatarColorString = "0000FF",
email = "bit@bitwarden.com",
initials = "BW",
dialog = null,
environmentUrl = Environment.Us.label,
passwordInput = "",
)

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.util.asText
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class EnvironmentExtensionsTest {
@Test
fun `labelOrBaseUrlHost should correctly convert US environment to the correct label`() {
val environment = Environment.Us
assertEquals(
environment.label,
environment.labelOrBaseUrlHost,
)
}
@Test
fun `labelOrBaseUrlHost should correctly convert EU environment to the correct label`() {
val environment = Environment.Eu
assertEquals(
environment.label,
environment.labelOrBaseUrlHost,
)
}
@Suppress("MaxLineLength")
@Test
fun `labelOrBaseUrlHost should correctly convert self hosted environment to the correct label`() {
val environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.bitwarden.com"),
)
assertEquals(
"vault.bitwarden.com".asText(),
environment.labelOrBaseUrlHost,
)
}
}