BIT-1575: Update cipher collections functionality (#904)

This commit is contained in:
Ramsey Smith 2024-01-31 12:04:28 -07:00 committed by Álison Fernandes
parent bb0c91ee5a
commit 2d0353d744
11 changed files with 297 additions and 30 deletions

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
@ -71,6 +72,15 @@ interface CiphersApi {
@Body body: ShareCipherJsonRequest, @Body body: ShareCipherJsonRequest,
): Result<SyncResponseJson.Cipher> ): Result<SyncResponseJson.Cipher>
/**
* Updates a cipher's collections.
*/
@PUT("ciphers/{cipherId}/collections")
suspend fun updateCipherCollections(
@Path("cipherId") cipherId: String,
@Body body: UpdateCipherCollectionsJsonRequest,
): Result<Unit>
/** /**
* Hard deletes a cipher. * Hard deletes a cipher.
*/ */

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents an update cipher collections request.
*
* @property collectionIds A list of collection ids associated with the cipher.
*/
@Serializable
data class UpdateCipherCollectionsJsonRequest(
@SerialName("CollectionIds")
val collectionIds: List<String>,
)

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
/** /**
@ -57,6 +58,14 @@ interface CiphersService {
body: ShareCipherJsonRequest, body: ShareCipherJsonRequest,
): Result<SyncResponseJson.Cipher> ): Result<SyncResponseJson.Cipher>
/**
* Attempt to update a cipher's collections.
*/
suspend fun updateCipherCollections(
cipherId: String,
body: UpdateCipherCollectionsJsonRequest,
): Result<Unit>
/** /**
* Attempt to hard delete a cipher. * Attempt to hard delete a cipher.
*/ */

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrg
import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -118,6 +119,15 @@ class CiphersServiceImpl(
body = body, body = body,
) )
override suspend fun updateCipherCollections(
cipherId: String,
body: UpdateCipherCollectionsJsonRequest,
): Result<Unit> =
ciphersApi.updateCipherCollections(
cipherId = cipherId,
body = body,
)
override suspend fun hardDeleteCipher(cipherId: String): Result<Unit> = override suspend fun hardDeleteCipher(cipherId: String): Result<Unit> =
ciphersApi.hardDeleteCipher(cipherId = cipherId) ciphersApi.hardDeleteCipher(cipherId = cipherId)

View file

@ -257,6 +257,15 @@ interface VaultRepository : VaultLockManager {
collectionIds: List<String>, collectionIds: List<String>,
): ShareCipherResult ): ShareCipherResult
/**
* Attempt to update a cipher with the given collectionIds.
*/
suspend fun updateCipherCollections(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult
/** /**
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a * Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE]. * [SendView.type] of [SendType.FILE].

View file

@ -38,6 +38,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateFolderResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateFolderResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
@ -753,7 +754,7 @@ class VaultRepositoryImpl(
cipherView: CipherView, cipherView: CipherView,
collectionIds: List<String>, collectionIds: List<String>,
): ShareCipherResult { ): ShareCipherResult {
val userId = activeUserId ?: return ShareCipherResult.Error(null) val userId = activeUserId ?: return ShareCipherResult.Error
return vaultSdkSource return vaultSdkSource
.encryptCipher( .encryptCipher(
userId = userId, userId = userId,
@ -768,12 +769,40 @@ class VaultRepositoryImpl(
), ),
) )
} }
.onSuccess { vaultDiskSource.saveCipher(userId = userId, cipher = it) }
.fold( .fold(
onFailure = { ShareCipherResult.Error(errorMessage = null) }, onFailure = { ShareCipherResult.Error },
onSuccess = { onSuccess = { ShareCipherResult.Success },
vaultDiskSource.saveCipher(userId = userId, cipher = it) )
ShareCipherResult.Success }
},
override suspend fun updateCipherCollections(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult {
val userId = activeUserId ?: return ShareCipherResult.Error
return ciphersService
.updateCipherCollections(
cipherId = cipherId,
body = UpdateCipherCollectionsJsonRequest(collectionIds = collectionIds),
)
.flatMap {
vaultSdkSource
.encryptCipher(
userId = userId,
cipherView = cipherView.copy(collectionIds = collectionIds),
)
}
.onSuccess { cipher ->
vaultDiskSource.saveCipher(
userId = userId,
cipher = cipher.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { ShareCipherResult.Success },
onFailure = { ShareCipherResult.Error },
) )
} }

View file

@ -10,8 +10,7 @@ sealed class ShareCipherResult {
data object Success : ShareCipherResult() data object Success : ShareCipherResult()
/** /**
* Generic error while sharing cipher. The optional [errorMessage] may be displayed directly in * Generic error while sharing cipher.
* the UI when present.
*/ */
data class Error(val errorMessage: String?) : ShareCipherResult() data object Error : ShareCipherResult()
} }

View file

@ -60,14 +60,14 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
) { cipherViewState, collectionsState, userState -> ) { cipherViewState, collectionsState, userState ->
VaultMoveToOrganizationAction.Internal.VaultDataReceive( VaultMoveToOrganizationAction.Internal.VaultDataReceive(
vaultData = combineDataStates( vaultData = combineDataStates(
dataState1 = cipherViewState.map { Unit }, dataState1 = cipherViewState,
dataState2 = collectionsState, dataState2 = collectionsState,
dataState3 = DataState.Loaded(userState).map { Unit }, dataState3 = DataState.Loaded(userState),
) { _, collectionsData, _ -> ) { cipherData, collectionsData, userData ->
Triple( Triple(
first = cipherViewState.data, first = cipherData,
second = collectionsData, second = collectionsData,
third = userState, third = userData,
) )
}, },
) )
@ -270,6 +270,12 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
cipherView: CipherView, cipherView: CipherView,
contentState: VaultMoveToOrganizationState.ViewState.Content, contentState: VaultMoveToOrganizationState.ViewState.Content,
) { ) {
val collectionIds = contentState
.selectedOrganization
.collections
.filter { it.isSelected }
.map { it.id }
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Loading( dialogState = VaultMoveToOrganizationState.DialogState.Loading(
@ -280,17 +286,21 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
trySendAction( trySendAction(
VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive( VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive(
vaultRepository.shareCipher( if (state.onlyShowCollections) {
cipherId = mutableStateFlow.value.vaultItemId, vaultRepository.updateCipherCollections(
cipherView = cipherView.copy( cipherId = mutableStateFlow.value.vaultItemId,
organizationId = contentState.selectedOrganizationId, cipherView = cipherView,
), collectionIds = collectionIds,
collectionIds = contentState )
.selectedOrganization } else {
.collections vaultRepository.shareCipher(
.filter { it.isSelected } cipherId = mutableStateFlow.value.vaultItemId,
.map { it.id }, cipherView = cipherView.copy(
), organizationId = contentState.selectedOrganizationId,
),
collectionIds = collectionIds,
)
},
), ),
) )
} }

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockAttachmentJsonResponse
@ -211,6 +212,22 @@ class CiphersServiceTest : BaseServiceTest() {
) )
} }
@Test
fun `updateCipherCollections should execute the updateCipherCollections API`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))
val result = ciphersService.updateCipherCollections(
cipherId = "mockId-1",
body = UpdateCipherCollectionsJsonRequest(
collectionIds = listOf("mockId-1"),
),
)
assertEquals(
Unit,
result.getOrThrow(),
)
}
@Test @Test
fun `restoreCipher should execute the restoreCipher API`() = runTest { fun `restoreCipher should execute the restoreCipher API`() = runTest {
server.enqueue(MockResponse().setResponseCode(200)) server.enqueue(MockResponse().setResponseCode(200))

View file

@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJ
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateFolderResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateFolderResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
@ -2748,7 +2749,7 @@ class VaultRepositoryTest {
) )
assertEquals( assertEquals(
ShareCipherResult.Error(null), ShareCipherResult.Error,
result, result,
) )
} }
@ -2818,7 +2819,7 @@ class VaultRepositoryTest {
) )
assertEquals( assertEquals(
ShareCipherResult.Error(errorMessage = null), ShareCipherResult.Error,
result, result,
) )
} }
@ -2853,7 +2854,129 @@ class VaultRepositoryTest {
) )
assertEquals( assertEquals(
ShareCipherResult.Error(errorMessage = null), ShareCipherResult.Error,
result,
)
}
@Test
fun `updateCipherCollections with no active user should return ShareCipherResult Error`() =
runTest {
fakeAuthDiskSource.userState = null
val result = vaultRepository.updateCipherCollections(
cipherId = "cipherId",
cipherView = mockk(),
collectionIds = emptyList(),
)
assertEquals(
ShareCipherResult.Error,
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `updateCipherCollections with cipherService updateCipherCollections success should return ShareCipherResultSuccess`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
coEvery {
ciphersService.updateCipherCollections(
cipherId = "mockId-1",
body = UpdateCipherCollectionsJsonRequest(
collectionIds = listOf("mockId-1"),
),
)
} returns Unit.asSuccess()
coEvery { vaultDiskSource.saveCipher(userId, any()) } just runs
val result = vaultRepository.updateCipherCollections(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1)
.copy(collectionIds = listOf("mockId-1")),
collectionIds = listOf("mockId-1"),
)
assertEquals(
ShareCipherResult.Success,
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `updateCipherCollections with updateCipherCollections shareCipher failure should return ShareCipherResultError`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
coEvery {
ciphersService.updateCipherCollections(
cipherId = "mockId-1",
body = UpdateCipherCollectionsJsonRequest(
collectionIds = listOf("mockId-1"),
),
)
} returns Throwable("Fail").asFailure()
coEvery { vaultDiskSource.saveCipher(userId, any()) } just runs
val result = vaultRepository.updateCipherCollections(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1)
.copy(collectionIds = listOf("mockId-1")),
collectionIds = listOf("mockId-1"),
)
assertEquals(
ShareCipherResult.Error,
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `updateCipherCollections with updateCipherCollections encryptCipher failure should return ShareCipherResultError`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns Throwable("Fail").asFailure()
coEvery {
ciphersService.updateCipherCollections(
cipherId = "mockId-1",
body = UpdateCipherCollectionsJsonRequest(
collectionIds = listOf("mockId-1"),
),
)
} returns Unit.asSuccess()
coEvery { vaultDiskSource.saveCipher(userId, any()) } just runs
val result = vaultRepository.updateCipherCollections(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1)
.copy(collectionIds = listOf("mockId-1")),
collectionIds = listOf("mockId-1"),
)
assertEquals(
ShareCipherResult.Error,
result, result,
) )
} }

View file

@ -301,7 +301,7 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
cipherView = createMockCipherView(number = 1), cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"), collectionIds = listOf("mockId-1"),
) )
} returns ShareCipherResult.Error(errorMessage = null) } returns ShareCipherResult.Error
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals( assertEquals(
initialState.copy( initialState.copy(
@ -378,6 +378,41 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `MoveClick with onlyShowCollections true should invoke updateCipherCollections`() =
runTest {
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultMoveToOrganizationState(
onlyShowCollections = true,
),
),
)
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1)))
coEvery {
vaultRepository.updateCipherCollections(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
} returns ShareCipherResult.Success
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick)
assertEquals(
VaultMoveToOrganizationEvent.NavigateBack,
awaitItem(),
)
}
coVerify {
vaultRepository.updateCipherCollections(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
}
}
private fun createViewModel( private fun createViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle, savedStateHandle: SavedStateHandle = initialSavedStateHandle,
vaultRepo: VaultRepository = vaultRepository, vaultRepo: VaultRepository = vaultRepository,
@ -403,11 +438,12 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Loading, viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Loading,
vaultItemId: String = "mockCipherId", vaultItemId: String = "mockCipherId",
dialogState: VaultMoveToOrganizationState.DialogState? = null, dialogState: VaultMoveToOrganizationState.DialogState? = null,
onlyShowCollections: Boolean = false,
): VaultMoveToOrganizationState = VaultMoveToOrganizationState( ): VaultMoveToOrganizationState = VaultMoveToOrganizationState(
vaultItemId = vaultItemId, vaultItemId = vaultItemId,
viewState = viewState, viewState = viewState,
dialogState = dialogState, dialogState = dialogState,
onlyShowCollections = false, onlyShowCollections = onlyShowCollections,
) )
} }