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.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository 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.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text 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.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.net.URI
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -282,23 +281,3 @@ sealed class LoginAction {
) : Internal() ) : 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,
)
}
}