From eac5516a940fbd5f7e77aaf22e8b0c7ac7145d8b Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 20 Aug 2024 13:15:44 -0500 Subject: [PATCH] PM-11154: Create basic Remove Master Password UI (#3782) --- .../auth/repository/model/Organization.kt | 6 + .../util/SyncResponseJsonExtensions.kt | 2 + .../network/model/SyncResponseJson.kt | 3 + .../RemovePasswordNavigation.kt | 31 ++++ .../removepassword/RemovePasswordScreen.kt | 168 ++++++++++++++++++ .../removepassword/RemovePasswordViewModel.kt | 107 +++++++++++ app/src/main/res/values/strings.xml | 1 + .../util/AuthDiskSourceExtensionsTest.kt | 13 ++ .../util/SyncResponseJsonExtensionsTest.kt | 11 +- .../util/UserStateJsonExtensionsTest.kt | 13 ++ .../network/model/SyncResponseProfileUtil.kt | 1 + .../network/service/SyncServiceTest.kt | 2 + .../RemovePasswordScreenTest.kt | 106 +++++++++++ .../RemovePasswordViewModelTest.kt | 106 +++++++++++ .../addedit/VaultAddEditViewModelTest.kt | 3 + .../addedit/util/CipherViewExtensionsTest.kt | 3 + .../VaultMoveToOrganizationViewModelTest.kt | 7 + .../VaultMoveToOrganizationExtensionsTest.kt | 7 + .../vault/feature/vault/VaultViewModelTest.kt | 7 + .../vault/util/UserStateExtensionsTest.kt | 23 +++ 20 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt index a0c61a9b4..4baabbedc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt @@ -1,12 +1,18 @@ package com.x8bit.bitwarden.data.auth.repository.model +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType + /** * Represents an organization a user may be a member of. * * @property id The ID of the organization. * @property name The name of the organization (if applicable). + * @property shouldUseKeyConnector Indicates that the organization uses a key connector. + * @property role The user's role in the organization. */ data class Organization( val id: String, val name: String?, + val shouldUseKeyConnector: Boolean, + val role: OrganizationType, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt index 10d093978..335f9b68c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt @@ -14,6 +14,8 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization = Organization( id = this.id, name = this.name, + shouldUseKeyConnector = this.shouldUseKeyConnector, + role = this.type, ) /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt index 2895bbee3..173d45b43 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt @@ -246,6 +246,9 @@ data class SyncResponseJson( @SerialName("usePolicies") val shouldUsePolicies: Boolean, + @SerialName("useKeyConnector") + val shouldUseKeyConnector: Boolean, + @SerialName("keyConnectorUrl") val keyConnectorUrl: String?, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordNavigation.kt new file mode 100644 index 000000000..df92d4262 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordNavigation.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.ui.auth.feature.removepassword + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +/** + * The route for navigating to the [RemovePasswordScreen]. + */ +const val REMOVE_PASSWORD_ROUTE: String = "remove_password" + +/** + * Add the Remove Password screen to the nav graph. + */ +fun NavGraphBuilder.removePasswordDestination() { + composable( + route = REMOVE_PASSWORD_ROUTE, + ) { + RemovePasswordScreen() + } +} + +/** + * Navigate to the Remove Password screen. + */ +fun NavController.navigateToRemovePassword( + navOptions: NavOptions? = null, +) { + this.navigate(REMOVE_PASSWORD_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt new file mode 100644 index 000000000..e32ee35ac --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt @@ -0,0 +1,168 @@ +package com.x8bit.bitwarden.ui.auth.feature.removepassword + +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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +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.asText +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * The top level composable for the Remove Password screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemovePasswordScreen( + viewModel: RemovePasswordViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + RemovePasswordDialogs( + dialogState = state.dialogState, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(RemovePasswordAction.DialogDismiss) } + }, + ) + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.remove_master_password), + scrollBehavior = scrollBehavior, + navigationIcon = null, + ) + }, + ) { innerPadding -> + RemovePasswordScreenContent( + state = state, + onContinueClick = remember(viewModel) { + { viewModel.trySendAction(RemovePasswordAction.ContinueClick) } + }, + onInputChanged = remember(viewModel) { + { viewModel.trySendAction(RemovePasswordAction.InputChanged(it)) } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } +} + +@Composable +private fun RemovePasswordScreenContent( + state: RemovePasswordState, + onContinueClick: () -> Unit, + onInputChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.description(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.master_password), + value = state.input, + onValueChange = onInputChanged, + showPasswordTestTag = "PasswordVisibilityToggle", + modifier = Modifier + .testTag(tag = "MasterPasswordEntry") + .standardHorizontalMargin() + .fillMaxWidth(), + autoFocus = true, + ) + Spacer(modifier = Modifier.height(24.dp)) + + BitwardenFilledButton( + label = stringResource(id = R.string.continue_text), + onClick = onContinueClick, + isEnabled = state.input.isNotEmpty(), + modifier = Modifier + .testTag(tag = "ContinueButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun RemovePasswordDialogs( + dialogState: RemovePasswordState.DialogState?, + onDismissRequest: () -> Unit, +) { + when (dialogState) { + is RemovePasswordState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissRequest, + ) + } + + null -> Unit + } +} + +@Preview(showBackground = true) +@Composable +private fun RemovePasswordScreen_preview() { + BitwardenTheme { + RemovePasswordScreenContent( + state = RemovePasswordState( + input = "", + description = "Organization is using SSO with a self-hosted key server.".asText(), + dialogState = null, + ), + onContinueClick = { }, + onInputChanged = { }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt new file mode 100644 index 000000000..224aa0a4b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt @@ -0,0 +1,107 @@ +package com.x8bit.bitwarden.ui.auth.feature.removepassword + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Manages application state for the Set Password screen. + */ +@HiltViewModel +class RemovePasswordViewModel @Inject constructor( + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: run { + val orgName = authRepository.userStateFlow.value + ?.activeAccount + ?.organizations + ?.firstOrNull { it.shouldUseKeyConnector } + ?.name + .orEmpty() + RemovePasswordState( + input = "", + description = R.string + .organization_is_using_sso_with_a_self_hosted_key_server + .asText(orgName), + dialogState = null, + ) + }, +) { + override fun handleAction(action: RemovePasswordAction) { + when (action) { + RemovePasswordAction.ContinueClick -> handleContinueClick() + is RemovePasswordAction.InputChanged -> handleInputChanged(action) + RemovePasswordAction.DialogDismiss -> handleDialogDismiss() + } + } + + private fun handleContinueClick() { + // TODO: Process removing the password (PM-11155) + } + + private fun handleInputChanged(action: RemovePasswordAction.InputChanged) { + mutableStateFlow.update { it.copy(input = action.input) } + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { it.copy(dialogState = null) } + } +} + +/** + * Models state of the Remove Password screen. + */ +@Parcelize +data class RemovePasswordState( + val input: String, + val description: Text, + val dialogState: DialogState?, +) : Parcelable { + /** + * Represents the current state of any dialogs on the screen. + */ + sealed class DialogState : Parcelable { + /** + * Represents an error dialog with the given [message] and optional [title]. If no title + * is specified a default will be provided. + */ + @Parcelize + data class Error( + val title: Text? = null, + val message: Text, + ) : DialogState() + } +} + +/** + * Models actions for the Remove Password screen. + */ +sealed class RemovePasswordAction { + /** + * Indicates that the user has clicked the continue button + */ + data object ContinueClick : RemovePasswordAction() + + /** + * The user has modified the input. + */ + data class InputChanged( + val input: String, + ) : RemovePasswordAction() + + /** + * Indicates that the dialog has been dismissed. + */ + data object DialogDismiss : RemovePasswordAction() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 346aae91f..c03e5b0ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -615,6 +615,7 @@ Scanning will happen automatically. Updating password Currently unable to update password Remove master password + %1$s is using SSO with a self-hosted key server. A master password is no longer required to log in for members of this organization. %1$s is using SSO with customer-managed encryption. Continuing will remove your master password from your account and require SSO to login. If you do not want to remove your master password, you may leave this organization. Leave organization diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt index e04eaacef..359b09158 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserSwitchingData +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import io.mockk.every import io.mockk.mockk @@ -187,6 +188,8 @@ class AuthDiskSourceExtensionsTest { Organization( id = "mockId-1", name = "mockName-1", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -196,6 +199,8 @@ class AuthDiskSourceExtensionsTest { Organization( id = "mockId-2", name = "mockName-2", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -205,6 +210,8 @@ class AuthDiskSourceExtensionsTest { Organization( id = "mockId-3", name = "mockName-3", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -241,6 +248,8 @@ class AuthDiskSourceExtensionsTest { Organization( id = "mockId-1", name = "mockName-1", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -269,6 +278,8 @@ class AuthDiskSourceExtensionsTest { Organization( id = "mockId-1", name = "mockName-1", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -278,6 +289,8 @@ class AuthDiskSourceExtensionsTest { Organization( id = "mockId-2", name = "mockName-2", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt index b712aa7c3..84cd40715 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy @@ -19,6 +20,8 @@ class SyncResponseJsonExtensionsTest { Organization( id = "mockId-1", name = "mockName-1", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), createMockOrganization(number = 1).toOrganization(), ) @@ -31,15 +34,19 @@ class SyncResponseJsonExtensionsTest { Organization( id = "mockId-1", name = "mockName-1", + shouldUseKeyConnector = true, + role = OrganizationType.ADMIN, ), Organization( id = "mockId-2", name = "mockName-2", + shouldUseKeyConnector = false, + role = OrganizationType.USER, ), ), listOf( - createMockOrganization(number = 1), - createMockOrganization(number = 2), + createMockOrganization(number = 1).copy(shouldUseKeyConnector = true), + createMockOrganization(number = 2).copy(type = OrganizationType.USER), ) .toOrganizations(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 55e513476..c83513632 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import io.mockk.every import io.mockk.mockk @@ -229,6 +230,8 @@ class UserStateJsonExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), isBiometricsEnabled = false, @@ -287,6 +290,8 @@ class UserStateJsonExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -320,6 +325,8 @@ class UserStateJsonExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), isBiometricsEnabled = true, @@ -374,6 +381,8 @@ class UserStateJsonExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), @@ -408,6 +417,8 @@ class UserStateJsonExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), isBiometricsEnabled = false, @@ -470,6 +481,8 @@ class UserStateJsonExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseProfileUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseProfileUtil.kt index eca66f0d2..468708f0e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseProfileUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseProfileUtil.kt @@ -37,6 +37,7 @@ fun createMockOrganization( ): SyncResponseJson.Profile.Organization = SyncResponseJson.Profile.Organization( shouldUsePolicies = shouldUsePolicies, + shouldUseKeyConnector = false, keyConnectorUrl = "mockKeyConnectorUrl-$number", type = OrganizationType.ADMIN, seats = 1, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt index a27d8618c..7d20a2728 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt @@ -58,6 +58,7 @@ private const val SYNC_SUCCESS_JSON = """ "organizations": [ { "usePolicies": false, + "useKeyConnector": false, "keyConnectorUrl": "mockKeyConnectorUrl-1", "type": 1, "seats": 1, @@ -132,6 +133,7 @@ private const val SYNC_SUCCESS_JSON = """ "providerOrganizations": [ { "usePolicies": false, + "useKeyConnector": false, "keyConnectorUrl": "mockKeyConnectorUrl-1", "type": 1, "seats": 1, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt new file mode 100644 index 000000000..85c0df969 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt @@ -0,0 +1,106 @@ +package com.x8bit.bitwarden.ui.auth.feature.removepassword + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.util.assertNoDialogExists +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class RemovePasswordScreenTest : BaseComposeTest() { + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns bufferedMutableSharedFlow() + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setup() { + composeTestRule.setContent { + RemovePasswordScreen( + viewModel = viewModel, + ) + } + } + + @Test + fun `dialog should update according to state`() { + val errorTitle = "message title" + val errorMessage = "Error message" + composeTestRule.assertNoDialogExists() + composeTestRule.onNodeWithText(text = errorTitle).assertDoesNotExist() + composeTestRule.onNodeWithText(text = errorMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = RemovePasswordState.DialogState.Error( + title = errorTitle.asText(), + message = errorMessage.asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(text = errorTitle) + .assert(hasAnyAncestor(isDialog())) + .isDisplayed() + composeTestRule + .onNodeWithText(text = errorMessage) + .assert(hasAnyAncestor(isDialog())) + .isDisplayed() + + mutableStateFlow.update { it.copy(dialogState = null) } + + composeTestRule.onNode(isDialog()).assertDoesNotExist() + } + + @Test + fun `description should update according to state`() { + val description = "description" + composeTestRule.onNodeWithText(text = description).assertDoesNotExist() + + mutableStateFlow.update { it.copy(description = description.asText()) } + + composeTestRule.onNodeWithText(text = description).isDisplayed() + } + + @Test + fun `continue button should update according to state`() { + composeTestRule.onNodeWithText(text = "Continue").performScrollTo().assertIsNotEnabled() + mutableStateFlow.update { it.copy(input = "a") } + composeTestRule.onNodeWithText(text = "Continue").performScrollTo().assertIsEnabled() + } + + @Test + fun `continue button click should emit ContinueClick`() { + mutableStateFlow.update { it.copy(input = "a") } + composeTestRule + .onNodeWithText(text = "Continue") + .performScrollTo() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(RemovePasswordAction.ContinueClick) + } + } +} + +private val DEFAULT_STATE = RemovePasswordState( + input = "", + dialogState = null, + description = "My org".asText(), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt new file mode 100644 index 000000000..64d4a1d12 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt @@ -0,0 +1,106 @@ +package com.x8bit.bitwarden.ui.auth.feature.removepassword + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.Organization +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RemovePasswordViewModelTest : BaseViewModelTest() { + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepository: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } + + @Test + fun `ContinueClick calls does nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(RemovePasswordAction.ContinueClick) + expectNoEvents() + } + } + + @Test + fun `InputChanged updates the state`() { + val input = "123" + val viewModel = createViewModel() + viewModel.trySendAction(RemovePasswordAction.InputChanged(input = input)) + assertEquals(DEFAULT_STATE.copy(input = input), viewModel.stateFlow.value) + } + + @Test + fun `DialogDismiss calls clears the dialog state`() = runTest { + val initialState = DEFAULT_STATE.copy( + dialogState = RemovePasswordState.DialogState.Error( + title = "title".asText(), + message = "message".asText(), + ), + ) + val viewModel = createViewModel(initialState) + + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.trySendAction(RemovePasswordAction.DialogDismiss) + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + private fun createViewModel( + state: RemovePasswordState? = null, + ): RemovePasswordViewModel = + RemovePasswordViewModel( + authRepository = authRepository, + savedStateHandle = SavedStateHandle(mapOf("state" to state)), + ) +} + +private const val ORGANIZATION_NAME: String = "My org" +private val DEFAULT_STATE = RemovePasswordState( + input = "", + dialogState = null, + description = R.string + .organization_is_using_sso_with_a_self_hosted_key_server + .asText(ORGANIZATION_NAME), +) + +private const val USER_ID: String = "user_id" +private val DEFAULT_ACCOUNT = UserState.Account( + userId = USER_ID, + name = "Active User", + email = "active@bitwarden.com", + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = listOf( + Organization( + id = "orgId", + name = ORGANIZATION_NAME, + shouldUseKeyConnector = true, + role = OrganizationType.USER, + ), + ), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, +) + +private val DEFAULT_USER_STATE = UserState( + activeUserId = USER_ID, + accounts = listOf(DEFAULT_ACCOUNT), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index fe3c0db50..2646112c6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView @@ -3878,6 +3879,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), isBiometricsEnabled = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 2b5461c9f..71a556a4a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView @@ -434,6 +435,8 @@ class CipherViewExtensionsTest { Organization( id = "mockOrganizationId-1", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), isBiometricsEnabled = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt index 451c9ed8b..c524ab4d5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -490,14 +491,20 @@ private val DEFAULT_USER_STATE = UserState( Organization( id = "mockOrganizationId-1", name = "mockOrganizationName-1", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), Organization( id = "mockOrganizationId-2", name = "mockOrganizationName-2", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), Organization( id = "mockOrganizationId-3", name = "mockOrganizationName-3", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt index 2aca7636e..9b0405ae0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt @@ -4,6 +4,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -102,14 +103,20 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState = Organization( id = "mockOrganizationId-1", name = "mockOrganizationName-1", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), Organization( id = "mockOrganizationId-2", name = "mockOrganizationName-2", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), Organization( id = "mockOrganizationId-3", name = "mockOrganizationName-3", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ) } else { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 036dd05b6..05eddb3e7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView @@ -187,6 +188,8 @@ class VaultViewModelTest : BaseViewModelTest() { Organization( id = "organiationId", name = "Test Organization", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -267,6 +270,8 @@ class VaultViewModelTest : BaseViewModelTest() { Organization( id = "organizationId", name = "Test Organization", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -471,6 +476,8 @@ class VaultViewModelTest : BaseViewModelTest() { Organization( id = "testOrganizationId", name = "Test Organization", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt index b92b2eb1e..b11b6e0ce 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt @@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -76,6 +77,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -97,6 +100,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -122,6 +127,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -147,6 +154,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -187,6 +196,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -225,6 +236,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -267,6 +280,8 @@ class UserStateExtensionsTest { Organization( id = "organizationId", name = "organizationName", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -336,10 +351,14 @@ class UserStateExtensionsTest { Organization( id = "organizationId-B", name = "Organization B", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), Organization( id = "organizationId-A", name = "Organization A", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null, @@ -385,10 +404,14 @@ class UserStateExtensionsTest { Organization( id = "organizationId-B", name = "Organization B", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), Organization( id = "organizationId-A", name = "Organization A", + shouldUseKeyConnector = false, + role = OrganizationType.ADMIN, ), ), trustedDevice = null,