BIT-1111: Add delete account logic (#252)

This commit is contained in:
David Perez 2023-11-17 11:11:35 -06:00 committed by Álison Fernandes
parent dd6e7639b5
commit db5c57664f
15 changed files with 622 additions and 26 deletions

View file

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

View file

@ -29,6 +29,7 @@ object AuthNetworkModule {
json: Json,
): AccountsService = AccountsServiceImpl(
accountsApi = retrofits.unauthenticatedApiRetrofit.create(),
authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
)

View file

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

View file

@ -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<Unit>
/**
* Make pre login request to get KDF params.
*/

View file

@ -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<Unit> =
authenticatedAccountsApi.deleteAccount(DeleteAccountRequestJson(masterPasswordHash))
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
accountsApi.preLogin(PreLoginRequestJson(email = email))

View file

@ -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<Unit>
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].

View file

@ -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<Unit> {
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,

View file

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

View file

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

View file

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

View file

@ -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<Unit, DeleteAccountEvent, DeleteAccountAction>(
initialState = Unit,
) {
class DeleteAccountViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>(
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<Unit>,
) : Internal()
}
}

View file

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

View file

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

View file

@ -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<DeleteAccountEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(Unit)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<DeleteAccountViewModel>(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,
)

View file

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