mirror of
https://github.com/bitwarden/android.git
synced 2026-06-02 02:36:58 -05:00
BIT-1111: Add delete account logic (#252)
This commit is contained in:
@@ -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 = """
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user