PM-11154: Create basic Remove Master Password UI (#3782)

This commit is contained in:
David Perez 2024-08-20 13:15:44 -05:00 committed by GitHub
parent 88b674f54c
commit eac5516a94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 618 additions and 2 deletions

View file

@ -1,12 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model 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. * Represents an organization a user may be a member of.
* *
* @property id The ID of the organization. * @property id The ID of the organization.
* @property name The name of the organization (if applicable). * @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( data class Organization(
val id: String, val id: String,
val name: String?, val name: String?,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
) )

View file

@ -14,6 +14,8 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
Organization( Organization(
id = this.id, id = this.id,
name = this.name, name = this.name,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
) )
/** /**

View file

@ -246,6 +246,9 @@ data class SyncResponseJson(
@SerialName("usePolicies") @SerialName("usePolicies")
val shouldUsePolicies: Boolean, val shouldUsePolicies: Boolean,
@SerialName("useKeyConnector")
val shouldUseKeyConnector: Boolean,
@SerialName("keyConnectorUrl") @SerialName("keyConnectorUrl")
val keyConnectorUrl: String?, val keyConnectorUrl: String?,

View file

@ -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)
}

View file

@ -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 = { },
)
}
}

View file

@ -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<RemovePasswordState, Unit, RemovePasswordAction>(
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()
}

View file

@ -615,6 +615,7 @@ Scanning will happen automatically.</string>
<string name="updating_password">Updating password</string> <string name="updating_password">Updating password</string>
<string name="update_password_error">Currently unable to update password</string> <string name="update_password_error">Currently unable to update password</string>
<string name="remove_master_password">Remove master password</string> <string name="remove_master_password">Remove master password</string>
<string name="organization_is_using_sso_with_a_self_hosted_key_server">%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.</string>
<string name="remove_master_password_warning">%1$s is using SSO with customer-managed encryption. Continuing will remove your master password from your account and require SSO to login.</string> <string name="remove_master_password_warning">%1$s is using SSO with customer-managed encryption. Continuing will remove your master password from your account and require SSO to login.</string>
<string name="remove_master_password_warning2">If you do not want to remove your master password, you may leave this organization.</string> <string name="remove_master_password_warning2">If you do not want to remove your master password, you may leave this organization.</string>
<string name="leave_organization">Leave organization</string> <string name="leave_organization">Leave organization</string>

View file

@ -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.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserSwitchingData 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 com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -187,6 +188,8 @@ class AuthDiskSourceExtensionsTest {
Organization( Organization(
id = "mockId-1", id = "mockId-1",
name = "mockName-1", name = "mockName-1",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -196,6 +199,8 @@ class AuthDiskSourceExtensionsTest {
Organization( Organization(
id = "mockId-2", id = "mockId-2",
name = "mockName-2", name = "mockName-2",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -205,6 +210,8 @@ class AuthDiskSourceExtensionsTest {
Organization( Organization(
id = "mockId-3", id = "mockId-3",
name = "mockName-3", name = "mockName-3",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -241,6 +248,8 @@ class AuthDiskSourceExtensionsTest {
Organization( Organization(
id = "mockId-1", id = "mockId-1",
name = "mockName-1", name = "mockName-1",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -269,6 +278,8 @@ class AuthDiskSourceExtensionsTest {
Organization( Organization(
id = "mockId-1", id = "mockId-1",
name = "mockName-1", name = "mockName-1",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -278,6 +289,8 @@ class AuthDiskSourceExtensionsTest {
Organization( Organization(
id = "mockId-2", id = "mockId-2",
name = "mockName-2", name = "mockName-2",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),

View file

@ -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.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation 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.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
@ -19,6 +20,8 @@ class SyncResponseJsonExtensionsTest {
Organization( Organization(
id = "mockId-1", id = "mockId-1",
name = "mockName-1", name = "mockName-1",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
createMockOrganization(number = 1).toOrganization(), createMockOrganization(number = 1).toOrganization(),
) )
@ -31,15 +34,19 @@ class SyncResponseJsonExtensionsTest {
Organization( Organization(
id = "mockId-1", id = "mockId-1",
name = "mockName-1", name = "mockName-1",
shouldUseKeyConnector = true,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "mockId-2", id = "mockId-2",
name = "mockName-2", name = "mockName-2",
shouldUseKeyConnector = false,
role = OrganizationType.USER,
), ),
), ),
listOf( listOf(
createMockOrganization(number = 1), createMockOrganization(number = 1).copy(shouldUseKeyConnector = true),
createMockOrganization(number = 2), createMockOrganization(number = 2).copy(type = OrganizationType.USER),
) )
.toOrganizations(), .toOrganizations(),
) )

View file

@ -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.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.repository.model.Environment 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 com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -229,6 +230,8 @@ class UserStateJsonExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -287,6 +290,8 @@ class UserStateJsonExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -320,6 +325,8 @@ class UserStateJsonExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
isBiometricsEnabled = true, isBiometricsEnabled = true,
@ -374,6 +381,8 @@ class UserStateJsonExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),
@ -408,6 +417,8 @@ class UserStateJsonExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -470,6 +481,8 @@ class UserStateJsonExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),

View file

@ -37,6 +37,7 @@ fun createMockOrganization(
): SyncResponseJson.Profile.Organization = ): SyncResponseJson.Profile.Organization =
SyncResponseJson.Profile.Organization( SyncResponseJson.Profile.Organization(
shouldUsePolicies = shouldUsePolicies, shouldUsePolicies = shouldUsePolicies,
shouldUseKeyConnector = false,
keyConnectorUrl = "mockKeyConnectorUrl-$number", keyConnectorUrl = "mockKeyConnectorUrl-$number",
type = OrganizationType.ADMIN, type = OrganizationType.ADMIN,
seats = 1, seats = 1,

View file

@ -58,6 +58,7 @@ private const val SYNC_SUCCESS_JSON = """
"organizations": [ "organizations": [
{ {
"usePolicies": false, "usePolicies": false,
"useKeyConnector": false,
"keyConnectorUrl": "mockKeyConnectorUrl-1", "keyConnectorUrl": "mockKeyConnectorUrl-1",
"type": 1, "type": 1,
"seats": 1, "seats": 1,
@ -132,6 +133,7 @@ private const val SYNC_SUCCESS_JSON = """
"providerOrganizations": [ "providerOrganizations": [
{ {
"usePolicies": false, "usePolicies": false,
"useKeyConnector": false,
"keyConnectorUrl": "mockKeyConnectorUrl-1", "keyConnectorUrl": "mockKeyConnectorUrl-1",
"type": 1, "type": 1,
"seats": 1, "seats": 1,

View file

@ -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<RemovePasswordViewModel>(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(),
)

View file

@ -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<UserState?>(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),
)

View file

@ -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.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository 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.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
@ -3878,6 +3879,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
isBiometricsEnabled = true, isBiometricsEnabled = true,

View file

@ -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.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.repository.model.Environment 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.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
@ -434,6 +435,8 @@ class CipherViewExtensionsTest {
Organization( Organization(
id = "mockOrganizationId-1", id = "mockOrganizationId-1",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
isBiometricsEnabled = true, isBiometricsEnabled = true,

View file

@ -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.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState 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.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.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@ -490,14 +491,20 @@ private val DEFAULT_USER_STATE = UserState(
Organization( Organization(
id = "mockOrganizationId-1", id = "mockOrganizationId-1",
name = "mockOrganizationName-1", name = "mockOrganizationName-1",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "mockOrganizationId-2", id = "mockOrganizationId-2",
name = "mockOrganizationName-2", name = "mockOrganizationName-2",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "mockOrganizationId-3", id = "mockOrganizationId-3",
name = "mockOrganizationName-3", name = "mockOrganizationName-3",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,

View file

@ -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.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.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.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -102,14 +103,20 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
Organization( Organization(
id = "mockOrganizationId-1", id = "mockOrganizationId-1",
name = "mockOrganizationName-1", name = "mockOrganizationName-1",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "mockOrganizationId-2", id = "mockOrganizationId-2",
name = "mockOrganizationName-2", name = "mockOrganizationName-2",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "mockOrganizationId-3", id = "mockOrganizationId-3",
name = "mockOrganizationName-3", name = "mockOrganizationName-3",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
) )
} else { } else {

View file

@ -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.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl 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.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
@ -187,6 +188,8 @@ class VaultViewModelTest : BaseViewModelTest() {
Organization( Organization(
id = "organiationId", id = "organiationId",
name = "Test Organization", name = "Test Organization",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -267,6 +270,8 @@ class VaultViewModelTest : BaseViewModelTest() {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "Test Organization", name = "Test Organization",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -471,6 +476,8 @@ class VaultViewModelTest : BaseViewModelTest() {
Organization( Organization(
id = "testOrganizationId", id = "testOrganizationId",
name = "Test Organization", name = "Test Organization",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
), ),

View file

@ -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.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.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.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
@ -76,6 +77,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -97,6 +100,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -122,6 +127,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -147,6 +154,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -187,6 +196,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -225,6 +236,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -267,6 +280,8 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId", id = "organizationId",
name = "organizationName", name = "organizationName",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -336,10 +351,14 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId-B", id = "organizationId-B",
name = "Organization B", name = "Organization B",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "organizationId-A", id = "organizationId-A",
name = "Organization A", name = "Organization A",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -385,10 +404,14 @@ class UserStateExtensionsTest {
Organization( Organization(
id = "organizationId-B", id = "organizationId-B",
name = "Organization B", name = "Organization B",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
Organization( Organization(
id = "organizationId-A", id = "organizationId-A",
name = "Organization A", name = "Organization A",
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
), ),
), ),
trustedDevice = null, trustedDevice = null,