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

This commit is contained in:
David Perez
2023-11-17 11:11:35 -06:00
committed by GitHub
parent 6cce047c2a
commit c8586542c1
15 changed files with 622 additions and 26 deletions

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