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 index 4bd9303c7a..7fe54f636f 100644 --- 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 @@ -6,10 +6,13 @@ import kotlinx.serialization.Serializable /** * Request body for deleting an account. * - * @param masterPasswordHash the master password (encrypted). + * @param masterPasswordHash The master password (encrypted). + * @param oneTimePassword The one time password. */ @Serializable data class DeleteAccountRequestJson( @SerialName("MasterPasswordHash") - val masterPasswordHash: String, + val masterPasswordHash: String?, + @SerialName("otp") + val oneTimePassword: 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 d639553be0..c489fec79a 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 @@ -18,7 +18,7 @@ interface AccountsService { /** * Make delete account request. */ - suspend fun deleteAccount(masterPasswordHash: String): Result + suspend fun deleteAccount(masterPasswordHash: String?, oneTimePassword: String?): Result /** * Request a one-time passcode that is sent to the user's email. 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 45f06aa100..22580720b7 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 @@ -31,8 +31,16 @@ class AccountsServiceImpl( ), ) - override suspend fun deleteAccount(masterPasswordHash: String): Result = - authenticatedAccountsApi.deleteAccount(DeleteAccountRequestJson(masterPasswordHash)) + override suspend fun deleteAccount( + masterPasswordHash: String?, + oneTimePassword: String?, + ): Result = + authenticatedAccountsApi.deleteAccount( + DeleteAccountRequestJson( + masterPasswordHash = masterPasswordHash, + oneTimePassword = oneTimePassword, + ), + ) override suspend fun requestOneTimePasscode(): Result = authenticatedAccountsApi.requestOtp() 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 e95d5082c8..8d49e5000c 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 @@ -122,9 +122,16 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { fun clearPendingAccountDeletion() /** - * Attempt to delete the current account and logout them out upon success. + * Attempt to delete the current account using the [masterPassword] and log them out + * upon success. */ - suspend fun deleteAccount(password: String): DeleteAccountResult + suspend fun deleteAccountWithMasterPassword(masterPassword: String): DeleteAccountResult + + /** + * Attempt to delete the current account using a [oneTimePassword] and log them out + * upon success. + */ + suspend fun deleteAccountWithOneTimePassword(oneTimePassword: String): DeleteAccountResult /** * Attempt to create a new user via SSO and log them into their account. Upon success the new 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 7d66a34927..984c07e31b 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 @@ -348,18 +348,42 @@ class AuthRepositoryImpl( mutableHasPendingAccountDeletionStateFlow.value = false } - override suspend fun deleteAccount(password: String): DeleteAccountResult { + override suspend fun deleteAccountWithMasterPassword( + masterPassword: String, + ): DeleteAccountResult { val profile = authDiskSource.userState?.activeAccount?.profile ?: return DeleteAccountResult.Error mutableHasPendingAccountDeletionStateFlow.value = true return authSdkSource .hashPassword( email = profile.email, - password = password, + password = masterPassword, kdf = profile.toSdkParams(), purpose = HashPurpose.SERVER_AUTHORIZATION, ) - .flatMap { hashedPassword -> accountsService.deleteAccount(hashedPassword) } + .flatMap { hashedPassword -> + accountsService.deleteAccount( + masterPasswordHash = hashedPassword, + oneTimePassword = null, + ) + } + .onSuccess { logout() } + .onFailure { clearPendingAccountDeletion() } + .fold( + onFailure = { DeleteAccountResult.Error }, + onSuccess = { DeleteAccountResult.Success }, + ) + } + + override suspend fun deleteAccountWithOneTimePassword( + oneTimePassword: String, + ): DeleteAccountResult { + mutableHasPendingAccountDeletionStateFlow.value = true + return accountsService + .deleteAccount( + masterPasswordHash = null, + oneTimePassword = oneTimePassword, + ) .onSuccess { logout() } .onFailure { clearPendingAccountDeletion() } .fold( 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 724925cbf5..7060000223 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 @@ -78,7 +78,7 @@ class DeleteAccountViewModel @Inject constructor( it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading) } viewModelScope.launch { - val result = authRepository.deleteAccount(action.masterPassword) + val result = authRepository.deleteAccountWithMasterPassword(action.masterPassword) sendAction(DeleteAccountAction.Internal.DeleteAccountComplete(result)) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt index 9b77b9d46d..7502216087 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt @@ -2,9 +2,17 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deletea import android.widget.Toast 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 @@ -15,15 +23,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +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.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenErrorButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton 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.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState +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 import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter /** @@ -62,6 +79,19 @@ fun DeleteAccountConfirmationScreen( onCloseClick = remember(viewModel) { { viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick) } }, + onDeleteAccountClick = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountClick) } + }, + onResendCodeClick = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountConfirmationAction.ResendCodeClick) } + }, + onVerificationCodeTextChange = remember(viewModel) { + { + viewModel.trySendAction( + DeleteAccountConfirmationAction.VerificationCodeTextChange(it), + ) + } + }, ) } @@ -97,15 +127,83 @@ private fun DeleteAccountConfirmationDialogs( visibilityState = LoadingDialogState.Shown(dialogState.title), ) } + null -> Unit } } +@Composable +private fun DeleteAccountConfirmationContent( + state: DeleteAccountConfirmationState, + onDeleteAccountClick: () -> Unit, + onResendCodeClick: () -> Unit, + onVerificationCodeTextChange: (verificationCode: String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(id = R.string.a_verification_code_was_sent_to_your_email), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenPasswordField( + value = state.verificationCode, + onValueChange = onVerificationCodeTextChange, + label = stringResource(id = R.string.verification_code), + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + autoFocus = true, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.confirm_your_identity), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + BitwardenErrorButton( + label = stringResource(id = R.string.delete_account), + onClick = onDeleteAccountClick, + isEnabled = state.verificationCode.isNotBlank(), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.resend_code), + onClick = onResendCodeClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DeleteAccountConfirmationScaffold( state: DeleteAccountConfirmationState, onCloseClick: () -> Unit, + onDeleteAccountClick: () -> Unit, + onResendCodeClick: () -> Unit, + onVerificationCodeTextChange: (verificationCode: String) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( @@ -122,8 +220,29 @@ private fun DeleteAccountConfirmationScaffold( ) }, ) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { - // TODO finish UI in BIT-2234 - } + DeleteAccountConfirmationContent( + state = state, + onDeleteAccountClick = onDeleteAccountClick, + onResendCodeClick = onResendCodeClick, + onVerificationCodeTextChange = onVerificationCodeTextChange, + modifier = Modifier.padding(innerPadding), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DeleteAccountConfirmationScreen_preview() { + BitwardenTheme { + DeleteAccountConfirmationScaffold( + state = DeleteAccountConfirmationState( + dialog = null, + verificationCode = "123456", + ), + onCloseClick = {}, + onDeleteAccountClick = {}, + onResendCodeClick = {}, + onVerificationCodeTextChange = {}, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt index 4333058c5d..2b665ab06d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt @@ -5,6 +5,8 @@ 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.data.auth.repository.model.DeleteAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult 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 @@ -12,6 +14,7 @@ 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 @@ -27,8 +30,10 @@ class DeleteAccountConfirmationViewModel @Inject constructor( ) : BaseViewModel< DeleteAccountConfirmationState, DeleteAccountConfirmationEvent, - DeleteAccountConfirmationAction,>( + DeleteAccountConfirmationAction, + >( initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountConfirmationState( + verificationCode = "", dialog = null, ), ) { @@ -37,16 +42,25 @@ class DeleteAccountConfirmationViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + viewModelScope.launch { authRepository.requestOneTimePasscode() } } override fun handleAction(action: DeleteAccountConfirmationAction) { when (action) { - DeleteAccountConfirmationAction.CloseClick -> handleCloseClick() - DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> { + is DeleteAccountConfirmationAction.CloseClick -> handleCloseClick() + is DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> { handleDeleteAccountAcknowledge() } - DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog() + is DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog() + is DeleteAccountConfirmationAction.DeleteAccountClick -> handleDeleteAccountClick() + + is DeleteAccountConfirmationAction.ResendCodeClick -> handleResendCodeClick() + is DeleteAccountConfirmationAction.VerificationCodeTextChange -> { + handleVerificationCodeTextChange(action) + } + + is DeleteAccountConfirmationAction.Internal -> handleInternalActions(action) } } @@ -62,6 +76,95 @@ class DeleteAccountConfirmationViewModel @Inject constructor( private fun handleDismissDialog() { mutableStateFlow.update { it.copy(dialog = null) } } + + private fun handleDeleteAccountClick() { + mutableStateFlow.update { + it.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ) + } + viewModelScope.launch { + sendAction( + DeleteAccountConfirmationAction.Internal.ReceiveDeleteAccountResult( + deleteAccountResult = authRepository.deleteAccountWithOneTimePassword( + oneTimePassword = state.verificationCode, + ), + ), + ) + } + } + + private fun handleResendCodeClick() { + mutableStateFlow.update { + it.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ) + } + viewModelScope.launch { + trySendAction( + DeleteAccountConfirmationAction.Internal.ReceiveRequestOtpResult( + requestOtpResult = authRepository.requestOneTimePasscode(), + ), + ) + } + } + + private fun handleVerificationCodeTextChange( + action: DeleteAccountConfirmationAction.VerificationCodeTextChange, + ) { + mutableStateFlow.update { it.copy(verificationCode = action.verificationCode) } + } + + private fun handleInternalActions(action: DeleteAccountConfirmationAction.Internal) { + when (action) { + is DeleteAccountConfirmationAction.Internal.ReceiveRequestOtpResult -> { + handleReceiveRequestOtpResult(action) + } + + is DeleteAccountConfirmationAction.Internal.ReceiveDeleteAccountResult -> { + handleReceiveDeleteAccountResult(action) + } + } + } + + private fun handleReceiveRequestOtpResult( + action: DeleteAccountConfirmationAction.Internal.ReceiveRequestOtpResult, + ) { + mutableStateFlow.update { + it.copy( + dialog = when (action.requestOtpResult) { + is RequestOtpResult.Error -> { + DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = R.string.generic_error_message.asText(), + ) + } + + is RequestOtpResult.Success -> null + }, + ) + } + } + + @Suppress("MaxLineLength") + private fun handleReceiveDeleteAccountResult( + action: DeleteAccountConfirmationAction.Internal.ReceiveDeleteAccountResult, + ) { + mutableStateFlow.update { currentState -> + currentState.copy( + dialog = when (action.deleteAccountResult) { + DeleteAccountResult.Error -> { + DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = R.string.generic_error_message.asText(), + ) + } + + DeleteAccountResult.Success -> { + DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess() + } + }, + ) + } + } } /** @@ -69,6 +172,7 @@ class DeleteAccountConfirmationViewModel @Inject constructor( */ @Parcelize data class DeleteAccountConfirmationState( + val verificationCode: String, val dialog: DeleteAccountConfirmationDialog?, ) : Parcelable { @@ -83,8 +187,7 @@ data class DeleteAccountConfirmationState( */ @Parcelize data class DeleteSuccess( - val message: Text = - R.string.your_account_has_been_permanently_deleted.asText(), + val message: Text = R.string.your_account_has_been_permanently_deleted.asText(), ) : DeleteAccountConfirmationDialog() /** @@ -95,8 +198,8 @@ data class DeleteAccountConfirmationState( */ @Parcelize data class Error( - val title: Text = R.string.an_error_has_occurred.asText(), - val message: Text, + val title: Text = R.string.an_error_has_occurred.asText(), + val message: Text, ) : DeleteAccountConfirmationDialog() /** @@ -106,7 +209,7 @@ data class DeleteAccountConfirmationState( */ @Parcelize data class Loading( - val title: Text = R.string.loading.asText(), + val title: Text = R.string.loading.asText(), ) : DeleteAccountConfirmationDialog() } } @@ -146,4 +249,43 @@ sealed class DeleteAccountConfirmationAction { * The user has acknowledged the account deletion. */ data object DeleteAccountAcknowledge : DeleteAccountConfirmationAction() + + /** + * The user has clicked the delete account button. + */ + data object DeleteAccountClick : DeleteAccountConfirmationAction() + + /** + * The user has clicked the resend code button. + */ + data object ResendCodeClick : DeleteAccountConfirmationAction() + + /** + * The user has changed the verification code. + * + * @param verificationCode The verification code the user has entered. + */ + data class VerificationCodeTextChange( + val verificationCode: String, + ) : DeleteAccountConfirmationAction() + + /** + * Internal actions for the view model. + */ + sealed class Internal : DeleteAccountConfirmationAction() { + + /** + * Indicates that a [RequestOtpResult] has been received. + */ + data class ReceiveRequestOtpResult( + val requestOtpResult: RequestOtpResult, + ) : Internal() + + /** + * Indicates that a [DeleteAccountResult] has been received. + */ + data class ReceiveDeleteAccountResult( + val deleteAccountResult: DeleteAccountResult, + ) : 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 2359d8b26a..d19f4681cb 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 @@ -47,10 +47,11 @@ class AccountsServiceTest : BaseServiceTest() { @Test fun `deleteAccount with empty response is success`() = runTest { val masterPasswordHash = "37y4d8r379r4789nt387r39k3dr87nr93" + val oneTimePassword = null val json = "" val response = MockResponse().setBody(json) server.enqueue(response) - assertTrue(service.deleteAccount(masterPasswordHash).isSuccess) + assertTrue(service.deleteAccount(masterPasswordHash, oneTimePassword).isSuccess) } @Test 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 6c0bbf518c..e9fc08840e 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 @@ -595,7 +595,10 @@ class AuthRepositoryTest { authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION) } returns hashedMasterPassword.asSuccess() coEvery { - accountsService.deleteAccount(hashedMasterPassword) + accountsService.deleteAccount( + masterPasswordHash = hashedMasterPassword, + oneTimePassword = null, + ) } returns Unit.asSuccess() fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 @@ -603,7 +606,7 @@ class AuthRepositoryTest { assertEquals(originalUserState, awaitItem()) // Deleting the account sets the pending deletion flag - repository.deleteAccount(password = masterPassword) + repository.deleteAccountWithMasterPassword(masterPassword = masterPassword) // Update the account. No changes are emitted because // the pending deletion blocks the update. @@ -619,7 +622,7 @@ class AuthRepositoryTest { @Test fun `delete account fails if not logged in`() = runTest { val masterPassword = "hello world" - val result = repository.deleteAccount(password = masterPassword) + val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword) assertEquals(DeleteAccountResult.Error, result) } @@ -632,7 +635,7 @@ class AuthRepositoryTest { authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION) } returns Throwable("Fail").asFailure() - val result = repository.deleteAccount(password = masterPassword) + val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword) assertEquals(DeleteAccountResult.Error, result) coVerify { @@ -650,20 +653,26 @@ class AuthRepositoryTest { authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION) } returns hashedMasterPassword.asSuccess() coEvery { - accountsService.deleteAccount(hashedMasterPassword) + accountsService.deleteAccount( + masterPasswordHash = hashedMasterPassword, + oneTimePassword = null, + ) } returns Throwable("Fail").asFailure() - val result = repository.deleteAccount(password = masterPassword) + val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword) assertEquals(DeleteAccountResult.Error, result) coVerify { authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION) - accountsService.deleteAccount(hashedMasterPassword) + accountsService.deleteAccount( + masterPasswordHash = hashedMasterPassword, + oneTimePassword = null, + ) } } @Test - fun `delete account succeeds`() = runTest { + fun `deleteAccountWithMasterPassword succeeds`() = runTest { val masterPassword = "hello world" val hashedMasterPassword = "dlrow olleh" fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 @@ -672,15 +681,45 @@ class AuthRepositoryTest { authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION) } returns hashedMasterPassword.asSuccess() coEvery { - accountsService.deleteAccount(hashedMasterPassword) + accountsService.deleteAccount( + masterPasswordHash = hashedMasterPassword, + oneTimePassword = null, + ) } returns Unit.asSuccess() - val result = repository.deleteAccount(password = masterPassword) + val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword) assertEquals(DeleteAccountResult.Success, result) coVerify { authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION) - accountsService.deleteAccount(hashedMasterPassword) + accountsService.deleteAccount( + masterPasswordHash = hashedMasterPassword, + oneTimePassword = null, + ) + } + } + + @Test + fun `deleteAccountWithOneTimePassword succeeds`() = runTest { + val oneTimePassword = "123456" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + accountsService.deleteAccount( + masterPasswordHash = null, + oneTimePassword = oneTimePassword, + ) + } returns Unit.asSuccess() + + val result = repository.deleteAccountWithOneTimePassword( + oneTimePassword = oneTimePassword, + ) + + assertEquals(DeleteAccountResult.Success, result) + coVerify { + accountsService.deleteAccount( + masterPasswordHash = null, + oneTimePassword = oneTimePassword, + ) } } 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 b83dedae33..15b1f3e612 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 @@ -81,7 +81,9 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { runTest { val viewModel = createViewModel() val masterPassword = "ckasb kcs ja" - coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success + coEvery { + authRepo.deleteAccountWithMasterPassword(masterPassword) + } returns DeleteAccountResult.Success viewModel.trySendAction( DeleteAccountAction.DeleteAccountConfirmDialogClick( @@ -95,7 +97,7 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { ) coVerify { - authRepo.deleteAccount(masterPassword) + authRepo.deleteAccountWithMasterPassword(masterPassword) } } @@ -122,7 +124,9 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest { val viewModel = createViewModel() val masterPassword = "ckasb kcs ja" - coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error + coEvery { + authRepo.deleteAccountWithMasterPassword(masterPassword) + } returns DeleteAccountResult.Error viewModel.trySendAction(DeleteAccountAction.DeleteAccountConfirmDialogClick(masterPassword)) @@ -136,7 +140,7 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { ) coVerify { - authRepo.deleteAccount(masterPassword) + authRepo.deleteAccountWithMasterPassword(masterPassword) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt index c085a11309..d9c6debd26 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt @@ -4,7 +4,10 @@ import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput 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 @@ -123,7 +126,52 @@ class DeleteAccountConfirmationScreenTest : BaseComposeTest() { viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge) } } + + @Test + fun `Delete account button click should emit DeleteAccountClick`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + verificationCode = "123456", + ) + } + + composeTestRule + .onNodeWithText("Delete account") + .performClick() + + verify { + viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountClick) + } + } + + @Test + fun `Resend code button click should emit ResendCodeClick`() { + composeTestRule + .onNodeWithText("Resend code") + .performClick() + + verify { + viewModel.trySendAction(DeleteAccountConfirmationAction.ResendCodeClick) + } + } + + @Test + fun `Verification code text input should emit VerificationCodeTextChange`() { + composeTestRule + .onAllNodesWithText("Verification code") + .onFirst() + .performTextInput("123456") + + verify { + viewModel.trySendAction( + DeleteAccountConfirmationAction.VerificationCodeTextChange("123456"), + ) + } + } } private val DEFAULT_STATE: DeleteAccountConfirmationState = - DeleteAccountConfirmationState(dialog = null) + DeleteAccountConfirmationState( + dialog = null, + verificationCode = "", + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt index 5e927976fe..5343619bcf 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt @@ -2,9 +2,14 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deletea 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.DeleteAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -80,6 +85,129 @@ class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() { assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } + @Test + @Suppress("MaxLineLength") + fun `on DeleteAccountClick with DeleteAccountResult Success should set dialog to Success`() = + runTest { + coEvery { + authRepo.deleteAccountWithOneTimePassword("123456") + } returns DeleteAccountResult.Success + val initialState = DEFAULT_STATE.copy( + verificationCode = "123456", + ) + val viewModel = createViewModel(state = initialState) + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.trySendAction( + DeleteAccountConfirmationAction.DeleteAccountClick, + ) + assertEquals( + initialState.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ), + awaitItem(), + ) + assertEquals( + initialState.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(), + ), + awaitItem(), + ) + } + coVerify { authRepo.deleteAccountWithOneTimePassword("123456") } + } + + @Test + @Suppress("MaxLineLength") + fun `on DeleteAccountClick with DeleteAccountResult Error should set dialog to Error`() = + runTest { + coEvery { + authRepo.deleteAccountWithOneTimePassword("123456") + } returns DeleteAccountResult.Error + val initialState = DEFAULT_STATE.copy( + verificationCode = "123456", + ) + val viewModel = createViewModel( + state = initialState, + ) + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.trySendAction( + DeleteAccountConfirmationAction.DeleteAccountClick, + ) + assertEquals( + initialState.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ), + awaitItem(), + ) + assertEquals( + initialState.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + coVerify { authRepo.deleteAccountWithOneTimePassword("123456") } + } + + @Test + @Suppress("MaxLineLength") + fun `on ResendCodeClick with requestOneTimePasscode Success should set dialog to null`() = + runTest { + coEvery { + authRepo.requestOneTimePasscode() + } returns RequestOtpResult.Success + val viewModel = createViewModel(state = DEFAULT_STATE) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction( + DeleteAccountConfirmationAction.ResendCodeClick, + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ), + awaitItem(), + ) + assertEquals(DEFAULT_STATE, awaitItem()) + } + coVerify { authRepo.requestOneTimePasscode() } + } + + @Test + @Suppress("MaxLineLength") + fun `on ResendCodeClick with requestOneTimePasscode Success should set dialog to Error`() = + runTest { + coEvery { + authRepo.requestOneTimePasscode() + } returns RequestOtpResult.Error(message = "Error") + val viewModel = createViewModel(state = DEFAULT_STATE) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction( + DeleteAccountConfirmationAction.ResendCodeClick, + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + coVerify { authRepo.requestOneTimePasscode() } + } + private fun createViewModel( authenticationRepository: AuthRepository = authRepo, state: DeleteAccountConfirmationState? = null, @@ -90,4 +218,7 @@ class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() { } private val DEFAULT_STATE: DeleteAccountConfirmationState = - DeleteAccountConfirmationState(dialog = null) + DeleteAccountConfirmationState( + dialog = null, + verificationCode = "", + )