From c8586542c1f16f95c83308f91f53f76f40c3ea9d Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 17 Nov 2023 11:11:35 -0600 Subject: [PATCH] BIT-1111: Add delete account logic (#252) --- .../network/api/AuthenticatedAccountsApi.kt | 17 +++ .../network/di/AuthNetworkModule.kt | 1 + .../network/model/DeleteAccountRequestJson.kt | 15 ++ .../network/service/AccountsService.kt | 5 + .../network/service/AccountsServiceImpl.kt | 6 + .../data/auth/repository/AuthRepository.kt | 5 + .../auth/repository/AuthRepositoryImpl.kt | 15 ++ .../BitwardenMasterPasswordDialog.kt | 74 ++++++++++ .../components/BitwardenPasswordField.kt | 21 ++- .../deleteaccount/DeleteAccountScreen.kt | 65 ++++++++- .../deleteaccount/DeleteAccountViewModel.kt | 120 ++++++++++++++-- .../network/service/AccountsServiceTest.kt | 12 ++ .../auth/repository/AuthRepositoryTest.kt | 71 +++++++++ .../deleteaccount/DeleteAccountScreenTest.kt | 135 +++++++++++++++++- .../DeleteAccountViewModelTest.kt | 86 +++++++++-- 15 files changed, 622 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeleteAccountRequestJson.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMasterPasswordDialog.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt new file mode 100644 index 0000000000..0d51f932ab --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.api + +import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson +import retrofit2.http.Body +import retrofit2.http.HTTP + +/** + * Defines raw calls under the /accounts API with authentication applied. + */ +interface AuthenticatedAccountsApi { + + /** + * Deletes the current account. + */ + @HTTP(method = "DELETE", path = "/accounts", hasBody = true) + suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt index e7483b3af9..cbab1585cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt @@ -29,6 +29,7 @@ object AuthNetworkModule { json: Json, ): AccountsService = AccountsServiceImpl( accountsApi = retrofits.unauthenticatedApiRetrofit.create(), + authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(), json = json, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeleteAccountRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeleteAccountRequestJson.kt new file mode 100644 index 0000000000..4bd9303c7a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/DeleteAccountRequestJson.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for deleting an account. + * + * @param masterPasswordHash the master password (encrypted). + */ +@Serializable +data class DeleteAccountRequestJson( + @SerialName("MasterPasswordHash") + val masterPasswordHash: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt index d65ba484d8..34ee2da7dc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -9,6 +9,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJs */ interface AccountsService { + /** + * Make delete account request. + */ + suspend fun deleteAccount(masterPasswordHash: String): Result + /** * Make pre login request to get KDF params. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt index 0cedf0d85e..6d0d97e4ac 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi +import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson @@ -11,9 +13,13 @@ import kotlinx.serialization.json.Json class AccountsServiceImpl constructor( private val accountsApi: AccountsApi, + private val authenticatedAccountsApi: AuthenticatedAccountsApi, private val json: Json, ) : AccountsService { + override suspend fun deleteAccount(masterPasswordHash: String): Result = + authenticatedAccountsApi.deleteAccount(DeleteAccountRequestJson(masterPasswordHash)) + override suspend fun preLogin(email: String): Result = accountsApi.preLogin(PreLoginRequestJson(email = email)) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index ad91264459..b366fcfd60 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -28,6 +28,11 @@ interface AuthRepository { */ var rememberedEmailAddress: String? + /** + * Attempt to delete the current account and logout them out upon success. + */ + suspend fun deleteAccount(password: String): Result + /** * Attempt to login with the given email and password. Updated access token will be reflected * in [authStateFlow]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index fbd6988e47..d43f4bdf35 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -17,10 +17,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.toUserState import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -82,6 +84,19 @@ class AuthRepositoryImpl constructor( authDiskSource.rememberedEmailAddress = value } + override suspend fun deleteAccount(password: String): Result { + val profile = authDiskSource.userState?.activeAccount?.profile + ?: return IllegalStateException("Not logged in.").asFailure() + return authSdkSource + .hashPassword( + email = profile.email, + password = password, + kdf = profile.toSdkParams(), + ) + .flatMap { hashedPassword -> accountsService.deleteAccount(hashedPassword) } + .onSuccess { logout() } + } + override suspend fun login( email: String, password: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMasterPasswordDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMasterPasswordDialog.kt new file mode 100644 index 0000000000..8e33781a4b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMasterPasswordDialog.kt @@ -0,0 +1,74 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R + +/** + * Represents a Bitwarden-styled dialog for entering your master password. + * + * @param onConfirmClick called when the confirm button is clicked and emits the entered password. + * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by + * tapping outside of it). + */ +@Composable +fun BitwardenMasterPasswordDialog( + onConfirmClick: (masterPassword: String) -> Unit, + onDismissRequest: () -> Unit, +) { + var masterPassword by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + BitwardenTextButton( + label = stringResource(id = R.string.cancel), + onClick = onDismissRequest, + ) + }, + confirmButton = { + BitwardenTextButton( + label = stringResource(id = R.string.submit), + isEnabled = masterPassword.isNotEmpty(), + onClick = { onConfirmClick(masterPassword) }, + ) + }, + title = { + Text( + text = stringResource(id = R.string.password_confirmation), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column { + Text( + text = stringResource(id = R.string.password_confirmation_desc), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.master_password), + value = masterPassword, + onValueChange = { masterPassword = it }, + modifier = Modifier.imePadding(), + autoFocus = true, + ) + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt index 2c5616f9be..bc7d67d5be 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordField.kt @@ -14,11 +14,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -42,6 +46,9 @@ import com.x8bit.bitwarden.R * @param onValueChange Callback that is triggered when the password changes. * @param modifier Modifier for the composable. * @param hint optional hint text that will appear below the text input. + * @param showPasswordTestTag The test tag to be used on the show password button (testing tool). + * @param autoFocus When set to true, the view will request focus after the first recomposition. + * Setting this to true on multiple fields at once may have unexpected consequences. */ @Composable fun BitwardenPasswordField( @@ -53,12 +60,16 @@ fun BitwardenPasswordField( modifier: Modifier = Modifier, hint: String? = null, showPasswordTestTag: String? = null, + autoFocus: Boolean = false, ) { + val focusRequester = remember { FocusRequester() } Column( modifier = modifier, ) { OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), textStyle = MaterialTheme.typography.bodyLarge, label = { Text(text = label) }, value = value, @@ -107,6 +118,9 @@ fun BitwardenPasswordField( ) } } + if (autoFocus) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } } /** @@ -120,6 +134,9 @@ fun BitwardenPasswordField( * @param hint optional hint text that will appear below the text input. * @param initialShowPassword The initial state of the show/hide password control. A value of * `false` (the default) indicates that that password should begin in the hidden state. + * @param showPasswordTestTag The test tag to be used on the show password button (testing tool). + * @param autoFocus When set to true, the view will request focus after the first recomposition. + * Setting this to true on multiple fields at once may have unexpected consequences. */ @Composable fun BitwardenPasswordField( @@ -130,6 +147,7 @@ fun BitwardenPasswordField( hint: String? = null, initialShowPassword: Boolean = false, showPasswordTestTag: String? = null, + autoFocus: Boolean = false, ) { var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) } BitwardenPasswordField( @@ -141,6 +159,7 @@ fun BitwardenPasswordField( onValueChange = onValueChange, hint = hint, showPasswordTestTag = showPasswordTestTag, + autoFocus = autoFocus, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt index 048eeec146..0445e6722f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt @@ -18,7 +18,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -28,10 +32,16 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState /** * Displays the delete account screen. @@ -43,6 +53,7 @@ fun DeleteAccountScreen( viewModel: DeleteAccountViewModel = hiltViewModel(), onNavigateBack: () -> Unit, ) { + val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current val resources = context.resources EventsEffect(viewModel = viewModel) { event -> @@ -55,6 +66,26 @@ fun DeleteAccountScreen( } } + when (val dialog = state.dialog) { + is DeleteAccountState.DeleteAccountDialog.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialog.message, + ), + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountAction.DismissDialog) } + }, + ) + + DeleteAccountState.DeleteAccountDialog.Loading, + + -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.loading.asText()), + ) + + null -> Unit + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -105,14 +136,10 @@ fun DeleteAccountScreen( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(24.dp)) - BitwardenErrorButton( - label = stringResource(id = R.string.delete_account), - onClick = remember(viewModel) { - { viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) } + DeleteAccountButton( + onConfirmationClick = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(it)) } }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(12.dp)) BitwardenOutlinedButton( @@ -128,3 +155,27 @@ fun DeleteAccountScreen( } } } + +@Composable +private fun DeleteAccountButton( + onConfirmationClick: (masterPassword: String) -> Unit, +) { + var showPasswordDialog by remember { mutableStateOf(false) } + if (showPasswordDialog) { + BitwardenMasterPasswordDialog( + onConfirmClick = { + showPasswordDialog = false + onConfirmationClick(it) + }, + onDismissRequest = { showPasswordDialog = false }, + ) + } + + BitwardenErrorButton( + label = stringResource(id = R.string.delete_account), + onClick = { showPasswordDialog = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt index cd85f85766..36d5d27c24 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt @@ -1,25 +1,51 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +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.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" + /** * View model for the account security screen. */ @HiltViewModel -class DeleteAccountViewModel @Inject constructor() : - BaseViewModel( - initialState = Unit, - ) { +class DeleteAccountViewModel @Inject constructor( + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState( + dialog = null, + ), +) { + + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } override fun handleAction(action: DeleteAccountAction) { when (action) { DeleteAccountAction.CancelClick -> handleCancelClick() DeleteAccountAction.CloseClick -> handleCloseClick() - DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick() + is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action) + DeleteAccountAction.DismissDialog -> handleDismissDialog() + is DeleteAccountAction.Internal.DeleteAccountComplete -> { + handleDeleteAccountComplete(action) + } } } @@ -31,9 +57,66 @@ class DeleteAccountViewModel @Inject constructor() : sendEvent(DeleteAccountEvent.NavigateBack) } - private fun handleDeleteAccountClick() { - // TODO: Delete the users account (BIT-1111) - sendEvent(DeleteAccountEvent.ShowToast("Not yet implemented.".asText())) + private fun handleDeleteAccountClick(action: DeleteAccountAction.DeleteAccountClick) { + mutableStateFlow.update { + it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading) + } + viewModelScope.launch { + val result = authRepository.deleteAccount(action.masterPassword) + sendAction(DeleteAccountAction.Internal.DeleteAccountComplete(result)) + } + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleDeleteAccountComplete( + action: DeleteAccountAction.Internal.DeleteAccountComplete, + ) { + action.result.fold( + onSuccess = { + mutableStateFlow.update { it.copy(dialog = null) } + // TODO: Display a dialog confirming account deletion (BIT-1184) + }, + onFailure = { + mutableStateFlow.update { + it.copy( + dialog = DeleteAccountState.DeleteAccountDialog.Error( + message = R.string.generic_error_message.asText(), + ), + ) + } + }, + ) + } +} + +/** + * Models state for the Delete Account screen. + */ +@Parcelize +data class DeleteAccountState( + val dialog: DeleteAccountDialog?, +) : Parcelable { + + /** + * Displays a dialog. + */ + sealed class DeleteAccountDialog : Parcelable { + /** + * Displays the error dialog when deleting an account fails. + */ + @Parcelize + data class Error( + val message: Text, + ) : DeleteAccountDialog() + + /** + * Displays the loading dialog when deleting an account. + */ + @Parcelize + data object Loading : DeleteAccountDialog() } } @@ -71,5 +154,24 @@ sealed class DeleteAccountAction { /** * The user has clicked the delete account button. */ - data object DeleteAccountClick : DeleteAccountAction() + data class DeleteAccountClick( + val masterPassword: String, + ) : DeleteAccountAction() + + /** + * The user has clicked to dismiss the dialog. + */ + data object DismissDialog : DeleteAccountAction() + + /** + * Models actions that the [DeleteAccountViewModel] itself might send. + */ + sealed class Internal : DeleteAccountAction() { + /** + * Indicates that the delete account request has completed. + */ + data class DeleteAccountComplete( + val result: Result, + ) : Internal() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index dcead6aa9b..d1bc3e27ec 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi +import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256 import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson @@ -17,13 +18,24 @@ import retrofit2.create class AccountsServiceTest : BaseServiceTest() { private val accountsApi: AccountsApi = retrofit.create() + private val authenticatedAccountsApi: AuthenticatedAccountsApi = retrofit.create() private val service = AccountsServiceImpl( accountsApi = accountsApi, + authenticatedAccountsApi = authenticatedAccountsApi, json = Json { ignoreUnknownKeys = true }, ) + @Test + fun `deleteAccount with empty response is success`() = runTest { + val masterPasswordHash = "37y4d8r379r4789nt387r39k3dr87nr93" + val json = "" + val response = MockResponse().setBody(json) + server.enqueue(response) + assertTrue(service.deleteAccount(masterPasswordHash).isSuccess) + } + @Test fun `preLogin with unknown kdf type be failure`() = runTest { val json = """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 1037d5dfc6..3d182b76a2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -26,10 +26,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.toUserState import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -44,6 +46,7 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -118,6 +121,74 @@ class AuthRepositoryTest { assertNull(repository.rememberedEmailAddress) } + @Test + fun `delete account fails if not logged in`() = runTest { + val masterPassword = "hello world" + val result = repository.deleteAccount(password = masterPassword) + assertTrue(result.isFailure) + } + + @Test + fun `delete account fails if hashPassword fails`() = runTest { + val masterPassword = "hello world" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams() + coEvery { + authSdkSource.hashPassword(EMAIL, masterPassword, kdf) + } returns Throwable("Fail").asFailure() + + val result = repository.deleteAccount(password = masterPassword) + + assertTrue(result.isFailure) + coVerify { + authSdkSource.hashPassword(EMAIL, masterPassword, kdf) + } + } + + @Test + fun `delete account fails if deleteAccount fails`() = runTest { + val masterPassword = "hello world" + val hashedMasterPassword = "dlrow olleh" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams() + coEvery { + authSdkSource.hashPassword(EMAIL, masterPassword, kdf) + } returns hashedMasterPassword.asSuccess() + coEvery { + accountsService.deleteAccount(hashedMasterPassword) + } returns Throwable("Fail").asFailure() + + val result = repository.deleteAccount(password = masterPassword) + + assertTrue(result.isFailure) + coVerify { + authSdkSource.hashPassword(EMAIL, masterPassword, kdf) + accountsService.deleteAccount(hashedMasterPassword) + } + } + + @Test + fun `delete account succeeds`() = runTest { + val masterPassword = "hello world" + val hashedMasterPassword = "dlrow olleh" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams() + coEvery { + authSdkSource.hashPassword(EMAIL, masterPassword, kdf) + } returns hashedMasterPassword.asSuccess() + coEvery { + accountsService.deleteAccount(hashedMasterPassword) + } returns Unit.asSuccess() + + val result = repository.deleteAccount(password = masterPassword) + + assertTrue(result.isSuccess) + coVerify { + authSdkSource.hashPassword(EMAIL, masterPassword, kdf) + accountsService.deleteAccount(hashedMasterPassword) + } + } + @Test fun `login when pre login fails should return Error with no message`() = runTest { coEvery { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt index 27cfcca895..796de01aa1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt @@ -1,10 +1,24 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +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.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.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -16,7 +30,7 @@ class DeleteAccountScreenTest : BaseComposeTest() { private val mutableEventFlow = MutableSharedFlow( extraBufferCapacity = Int.MAX_VALUE, ) - private val mutableStateFlow = MutableStateFlow(Unit) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow @@ -37,4 +51,123 @@ class DeleteAccountScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(DeleteAccountEvent.NavigateBack) assertTrue(onNavigateBackCalled) } + + @Test + fun `cancel click should emit CancelClick`() { + composeTestRule.onNodeWithText("Cancel").performScrollTo().performClick() + verify { viewModel.trySendAction(DeleteAccountAction.CancelClick) } + } + + @Test + fun `loading dialog presence should update with dialog state`() { + composeTestRule + .onAllNodesWithText("Loading") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading) + } + + composeTestRule + .onAllNodesWithText("Loading") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `error dialog presence should update with dialog state`() { + val message = "hello world" + composeTestRule + .onAllNodesWithText(message) + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Error(message.asText())) + } + + composeTestRule + .onAllNodesWithText(message) + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `delete account dialog should dismiss on cancel click`() { + composeTestRule + .onAllNodesWithText("Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + composeTestRule + .onAllNodesWithText("Delete account") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule + .onAllNodesWithText("Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + } + + @Test + fun `delete account dialog should emit DeleteAccountClick on submit click`() { + val password = "hello world" + composeTestRule + .onAllNodesWithText("Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + composeTestRule + .onAllNodesWithText("Delete account") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + + composeTestRule + .onAllNodesWithText("Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsNotEnabled() + + composeTestRule + .onAllNodesWithText("Master password") + .filterToOne(hasAnyAncestor(isDialog())) + .performTextInput(password) + + composeTestRule + .onAllNodesWithText("Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsEnabled() + .performClick() + + composeTestRule + .onAllNodesWithText("Master password confirmation") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + verify { + viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(password)) + } + } } + +private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState( + dialog = null, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt index c65be0ffb9..1da3786cd2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt @@ -1,14 +1,39 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount +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.platform.util.asFailure +import com.x8bit.bitwarden.data.platform.util.asSuccess 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.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class DeleteAccountViewModelTest : BaseViewModelTest() { + private val authRepo: AuthRepository = mockk(relaxed = true) + + @Test + fun `initial state should be correct when not set`() { + val viewModel = createViewModel(state = null) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `initial state should be correct when set`() { + val state = DEFAULT_STATE.copy( + dialog = DeleteAccountState.DeleteAccountDialog.Error("Hello".asText()), + ) + val viewModel = createViewModel(state = state) + assertEquals(state, viewModel.stateFlow.value) + } + @Test fun `on CancelClick should emit NavigateBack`() = runTest { val viewModel = createViewModel() @@ -28,16 +53,61 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { } @Test - fun `on DeleteAccountClick should emit ShowToast`() = runTest { + fun `on DeleteAccountClick should make the delete call`() = runTest { val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) - assertEquals( - DeleteAccountEvent.ShowToast("Not yet implemented.".asText()), - awaitItem(), - ) + val masterPassword = "ckasb kcs ja" + coEvery { authRepo.deleteAccount(masterPassword) } returns Unit.asSuccess() + + viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword)) + + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + coVerify { + authRepo.deleteAccount(masterPassword) } } - private fun createViewModel(): DeleteAccountViewModel = DeleteAccountViewModel() + @Test + fun `on DeleteAccountClick should update dialog state`() = runTest { + val viewModel = createViewModel() + val masterPassword = "ckasb kcs ja" + coEvery { authRepo.deleteAccount(masterPassword) } returns Throwable("Fail").asFailure() + + viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword)) + + assertEquals( + DEFAULT_STATE.copy( + dialog = DeleteAccountState.DeleteAccountDialog.Error( + message = R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + + coVerify { + authRepo.deleteAccount(masterPassword) + } + } + + @Test + fun `on DismissDialog should clear dialog state`() = runTest { + val state = DEFAULT_STATE.copy( + dialog = DeleteAccountState.DeleteAccountDialog.Error("Hello".asText()), + ) + val viewModel = createViewModel(state = state) + + viewModel.trySendAction(DeleteAccountAction.DismissDialog) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + private fun createViewModel( + authenticationRepository: AuthRepository = authRepo, + state: DeleteAccountState? = DEFAULT_STATE, + ): DeleteAccountViewModel = DeleteAccountViewModel( + authRepository = authenticationRepository, + savedStateHandle = SavedStateHandle().apply { set("state", state) }, + ) } + +private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState( + dialog = null, +)