BIT-2234: Delete Account Confirmation Screen (functionality) (#1290)

This commit is contained in:
Ramsey Smith 2024-04-23 09:43:06 -06:00 committed by Álison Fernandes
parent d64457aa0f
commit 7b08e1abb8
13 changed files with 567 additions and 41 deletions

View file

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

View file

@ -18,7 +18,7 @@ interface AccountsService {
/**
* Make delete account request.
*/
suspend fun deleteAccount(masterPasswordHash: String): Result<Unit>
suspend fun deleteAccount(masterPasswordHash: String?, oneTimePassword: String?): Result<Unit>
/**
* Request a one-time passcode that is sent to the user's email.

View file

@ -31,8 +31,16 @@ class AccountsServiceImpl(
),
)
override suspend fun deleteAccount(masterPasswordHash: String): Result<Unit> =
authenticatedAccountsApi.deleteAccount(DeleteAccountRequestJson(masterPasswordHash))
override suspend fun deleteAccount(
masterPasswordHash: String?,
oneTimePassword: String?,
): Result<Unit> =
authenticatedAccountsApi.deleteAccount(
DeleteAccountRequestJson(
masterPasswordHash = masterPasswordHash,
oneTimePassword = oneTimePassword,
),
)
override suspend fun requestOneTimePasscode(): Result<Unit> =
authenticatedAccountsApi.requestOtp()

View file

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

View file

@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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