Remove the send password (#579)

This commit is contained in:
David Perez 2024-01-11 20:06:30 -06:00 committed by Álison Fernandes
parent 7a6088a23d
commit 3f7fe90c5c
15 changed files with 375 additions and 13 deletions

View file

@ -35,4 +35,10 @@ interface SendsApi {
*/
@DELETE("sends/{sendId}")
suspend fun deleteSend(@Path("sendId") sendId: String): Result<Unit>
/**
* Deletes a send.
*/
@PUT("sends/{sendId}/remove-password")
suspend fun removeSendPassword(@Path("sendId") sendId: String): Result<SyncResponseJson.Send>
}

View file

@ -24,9 +24,16 @@ interface SendsService {
): Result<UpdateSendResponseJson>
/**
* Attempt to delete a cipher.
* Attempt to delete a send.
*/
suspend fun deleteSend(
sendId: String,
): Result<Unit>
/**
* Attempt to remove password protection from a send.
*/
suspend fun removeSendPassword(
sendId: String,
): Result<UpdateSendResponseJson>
}

View file

@ -40,4 +40,18 @@ class SendsServiceImpl(
override suspend fun deleteSend(sendId: String): Result<Unit> =
sendsApi.deleteSend(sendId = sendId)
override suspend fun removeSendPassword(sendId: String): Result<UpdateSendResponseJson> =
sendsApi
.removeSendPassword(sendId = sendId)
.map { UpdateSendResponseJson.Success(send = it) }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<UpdateSendResponseJson.Invalid>(
code = 400,
json = json,
)
?: throw throwable
}
}

View file

@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
@ -151,4 +152,9 @@ interface VaultRepository : VaultLockManager {
sendId: String,
sendView: SendView,
): UpdateSendResult
/**
* Attempt to remove the password from a send.
*/
suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult
}

View file

@ -30,6 +30,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
@ -443,6 +444,34 @@ class VaultRepositoryImpl(
)
}
override suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult {
val userId = requireNotNull(activeUserId)
return sendsService
.removeSendPassword(sendId = sendId)
.fold(
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
RemovePasswordSendResult.Error(errorMessage = response.message)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.getOrNull()
?.let { RemovePasswordSendResult.Success(sendView = it) }
?: RemovePasswordSendResult.Error(errorMessage = null)
}
}
},
onFailure = { RemovePasswordSendResult.Error(errorMessage = null) },
)
}
private fun storeProfileData(
syncResponse: SyncResponseJson,
) {

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.core.SendView
/**
* Models result of removing the password protection from a send.
*/
sealed class RemovePasswordSendResult {
/**
* Send has had the password protection successfully removed and contains the decrypted
* [SendView].
*/
data class Success(val sendView: SendView) : RemovePasswordSendResult()
/**
* Generic error while removing the password protection from a send. The optional
* [errorMessage] may be displayed directly in the UI when present.
*/
data class Error(val errorMessage: String?) : RemovePasswordSendResult()
}

View file

@ -32,7 +32,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
/**
* Displays new send UX.
@ -92,7 +92,7 @@ fun AddSendScreen(
)
if (!state.isAddMode) {
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
menuItemDataList = listOfNotNull(
OverflowMenuItemData(
text = stringResource(id = R.string.remove_password),
onClick = remember(viewModel) {
@ -102,7 +102,8 @@ fun AddSendScreen(
)
}
},
),
)
.takeIf { state.hasPassword },
OverflowMenuItemData(
text = stringResource(id = R.string.copy_link),
onClick = remember(viewModel) {
@ -121,7 +122,8 @@ fun AddSendScreen(
{ viewModel.trySendAction(AddSendAction.DeleteClick) }
},
),
),
)
.toPersistentList(),
)
}
},

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -70,6 +71,7 @@ class AddSendViewModel @Inject constructor(
.plusWeeks(1),
expirationDate = null,
sendUrl = null,
hasPassword = false,
),
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "",
@ -139,6 +141,10 @@ class AddSendViewModel @Inject constructor(
private fun handleInternalAction(action: AddSendAction.Internal): Unit = when (action) {
is AddSendAction.Internal.CreateSendResultReceive -> handleCreateSendResultReceive(action)
is AddSendAction.Internal.UpdateSendResultReceive -> handleUpdateSendResultReceive(action)
is AddSendAction.Internal.RemovePasswordResultReceive -> handleRemovePasswordResultReceive(
action,
)
is AddSendAction.Internal.UserStateReceive -> handleUserStateReceive(action)
is AddSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
}
@ -200,6 +206,32 @@ class AddSendViewModel @Inject constructor(
}
}
private fun handleRemovePasswordResultReceive(
action: AddSendAction.Internal.RemovePasswordResultReceive,
) {
when (val result = action.result) {
is RemovePasswordSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = result
.errorMessage
?.asText()
?: R.string.generic_error_message.asText(),
),
)
}
}
is RemovePasswordSendResult.Success -> {
updateCommonContent { it.copy(hasPassword = false) }
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(AddSendEvent.ShowToast(message = R.string.send_password_removed.asText()))
}
}
}
private fun handleUserStateReceive(action: AddSendAction.Internal.UserStateReceive) {
mutableStateFlow.update {
it.copy(isPremiumUser = action.userState?.activeAccount?.isPremium == true)
@ -288,8 +320,22 @@ class AddSendViewModel @Inject constructor(
}
private fun handleRemovePasswordClick() {
// TODO Add remove password support (BIT-1435)
sendEvent(AddSendEvent.ShowToast("Not yet implemented".asText()))
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> Unit
is AddSendType.EditItem -> {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Loading(
message = R.string.removing_send_password.asText(),
),
)
}
viewModelScope.launch {
val result = vaultRepo.removePasswordSend(addSendType.sendItemId)
sendAction(AddSendAction.Internal.RemovePasswordResultReceive(result))
}
}
}
}
private fun handleShareLinkClick() {
@ -516,6 +562,12 @@ data class AddSendState(
*/
val isAddMode: Boolean get() = addSendType is AddSendType.AddItem
/**
* Helper to determine if the currently displayed send has a password already set.
*/
val hasPassword: Boolean
get() = (viewState as? ViewState.Content)?.common?.hasPassword == true
/**
* Represents the specific view states for the [AddSendScreen].
*/
@ -566,6 +618,7 @@ data class AddSendState(
val deletionDate: ZonedDateTime,
val expirationDate: ZonedDateTime?,
val sendUrl: String?,
val hasPassword: Boolean,
) : Parcelable {
val dateFormatPattern: String get() = "M/d/yyyy"
@ -767,6 +820,11 @@ sealed class AddSendAction {
*/
data class UpdateSendResultReceive(val result: UpdateSendResult) : Internal()
/**
* Indicates a result for removing the password from a send has been received.
*/
data class RemovePasswordResultReceive(val result: RemovePasswordSendResult) : Internal()
/**
* Indicates that the send item data has been received.
*/

View file

@ -28,6 +28,7 @@ fun SendView.toViewState(
deletionDate = ZonedDateTime.ofInstant(this.deletionDate, clock.zone),
expirationDate = this.expirationDate?.let { ZonedDateTime.ofInstant(it, clock.zone) },
sendUrl = this.toSendUrl(baseWebSendUrl),
hasPassword = this.hasPassword,
),
selectedType = when (type) {
SendType.TEXT -> {

View file

@ -70,6 +70,32 @@ class SendsServiceTest : BaseServiceTest() {
val result = sendsService.deleteSend(sendId = "send-id-1")
assertEquals(Unit, result.getOrThrow())
}
@Test
fun `removeSendPassword with success response should return a Success with the correct send`() =
runTest {
server.enqueue(MockResponse().setBody(CREATE_UPDATE_SEND_SUCCESS_JSON))
val result = sendsService.removeSendPassword(sendId = "send-id-1")
assertEquals(
UpdateSendResponseJson.Success(send = createMockSend(number = 1)),
result.getOrThrow(),
)
}
@Suppress("MaxLineLength")
@Test
fun `removeSendPassword with an invalid response should return an Invalid with the correct data`() =
runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody(UPDATE_SEND_INVALID_JSON))
val result = sendsService.removeSendPassword(sendId = "send-id-1")
assertEquals(
UpdateSendResponseJson.Invalid(
message = "You do not have permission to edit this.",
validationErrors = null,
),
result.getOrThrow(),
)
}
}
private const val CREATE_UPDATE_SEND_SUCCESS_JSON = """

View file

@ -49,6 +49,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
@ -1591,6 +1592,65 @@ class VaultRepositoryTest {
assertEquals(UpdateSendResult.Success(mockSendViewResult), result)
}
@Test
@Suppress("MaxLineLength")
fun `removePasswordSend with sendsService removeSendPassword Error should return RemovePasswordSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
coEvery {
sendsService.removeSendPassword(sendId = sendId)
} returns Throwable("Fail").asFailure()
val result = vaultRepository.removePasswordSend(sendId = sendId)
assertEquals(RemovePasswordSendResult.Error(errorMessage = null), result)
}
@Test
@Suppress("MaxLineLength")
fun `removePasswordSend with sendsService removeSendPassword Success and vaultSdkSource decryptSend Failure should return RemovePasswordSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSend = createMockSend(number = 1)
coEvery {
sendsService.removeSendPassword(sendId = sendId)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
coEvery {
vaultSdkSource.decryptSend(userId = userId, send = createMockSdkSend(number = 1))
} returns Throwable("Fail").asFailure()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
val result = vaultRepository.removePasswordSend(sendId = sendId)
assertEquals(RemovePasswordSendResult.Error(errorMessage = null), result)
}
@Test
@Suppress("MaxLineLength")
fun `removePasswordSend with sendsService removeSendPassword Success should return RemovePasswordSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val mockSend = createMockSend(number = 1)
coEvery {
sendsService.removeSendPassword(sendId = sendId)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
coEvery {
vaultSdkSource.decryptSend(userId = userId, send = createMockSdkSend(number = 1))
} returns mockSendView.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
val result = vaultRepository.removePasswordSend(sendId = sendId)
assertEquals(RemovePasswordSendResult.Success(mockSendView), result)
}
//region Helper functions
/**

View file

@ -124,6 +124,24 @@ class AddSendScreenTest : BaseComposeTest() {
.isDisplayed()
}
@Test
fun `overflow remove password button should be hidden when hasPassword is false`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
addSendType = AddSendType.EditItem(sendItemId = "sendId"),
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(hasPassword = false),
),
)
composeTestRule
.onNodeWithContentDescription("More")
.performClick()
composeTestRule
.onNodeWithText("Remove password")
.assertDoesNotExist()
}
@Test
fun `on overflow remove password button click should send RemovePasswordClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
@ -790,6 +808,7 @@ class AddSendScreenTest : BaseComposeTest() {
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
expirationDate = null,
sendUrl = null,
hasPassword = true,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -292,18 +293,127 @@ class AddSendViewModelTest : BaseViewModelTest() {
}
@Test
fun `RemovePasswordClick should send ShowToast`() = runTest {
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(addSendType = AddSendType.EditItem("sendId")),
addSendType = AddSendType.EditItem("sendId"),
)
fun `in add item state, RemovePasswordClick should do nothing`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AddSendAction.RemovePasswordClick)
assertEquals(AddSendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `in edit item state, RemovePasswordClick vaultRepository removePasswordSend Error without message should show default error dialog`() =
runTest {
val sendId = "mockId-1"
coEvery {
vaultRepository.removePasswordSend(sendId)
} returns RemovePasswordSendResult.Error(errorMessage = null)
val initialState = DEFAULT_STATE.copy(
addSendType = AddSendType.EditItem(sendItemId = sendId),
)
val mockSendView = createMockSendView(number = 1)
every {
mockSendView.toViewState(clock, DEFAULT_ENVIRONMENT_URL)
} returns DEFAULT_VIEW_STATE
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
val viewModel = createViewModel(
state = initialState,
addSendType = AddSendType.EditItem(sendItemId = sendId),
)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(AddSendAction.RemovePasswordClick)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Loading(
message = R.string.removing_send_password.asText(),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in edit item state, RemovePasswordClick vaultRepository removePasswordSend Error with message should show error dialog with message`() =
runTest {
val sendId = "mockId-1"
val errorMessage = "Fail"
coEvery {
vaultRepository.removePasswordSend(sendId)
} returns RemovePasswordSendResult.Error(errorMessage = errorMessage)
val mockSendView = createMockSendView(number = 1)
every {
mockSendView.toViewState(clock, DEFAULT_ENVIRONMENT_URL)
} returns DEFAULT_VIEW_STATE
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
val initialState = DEFAULT_STATE.copy(
addSendType = AddSendType.EditItem(sendItemId = sendId),
)
val viewModel = createViewModel(
state = initialState,
addSendType = AddSendType.EditItem(sendItemId = sendId),
)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(AddSendAction.RemovePasswordClick)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Loading(
message = R.string.removing_send_password.asText(),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = errorMessage.asText(),
),
),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in edit item state, RemovePasswordClick vaultRepository removePasswordSend Success should show toast`() =
runTest {
val sendId = "mockId-1"
val mockSendView = createMockSendView(number = 1)
coEvery {
vaultRepository.removePasswordSend(sendId)
} returns RemovePasswordSendResult.Success(mockSendView)
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(addSendType = AddSendType.EditItem(sendItemId = sendId)),
addSendType = AddSendType.EditItem(sendItemId = sendId),
)
viewModel.eventFlow.test {
viewModel.trySendAction(AddSendAction.RemovePasswordClick)
assertEquals(
AddSendEvent.ShowToast(R.string.send_password_removed.asText()),
awaitItem(),
)
}
}
@Test
fun `ShareLinkClick should send ShowToast`() = runTest {
val viewModel = createViewModel(
@ -605,6 +715,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
deletionDate = ZonedDateTime.parse("2023-11-03T00:00Z"),
expirationDate = null,
sendUrl = null,
hasPassword = false,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(

View file

@ -70,6 +70,7 @@ private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
sendUrl = null,
hasPassword = false,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(

View file

@ -60,6 +60,7 @@ private val DEFAULT_COMMON: AddSendState.ViewState.Content.Common =
ZoneOffset.UTC,
),
sendUrl = "www.test.com/mockAccessId-1/mockKey-1",
hasPassword = true,
)
private val DEFAULT_TEXT_TYPE: AddSendState.ViewState.Content.SendType.Text =