BIT-1547: Hook up remaining push notification sync handling (#848)

This commit is contained in:
Sean Weiser 2024-01-29 22:15:59 -06:00 committed by Álison Fernandes
parent d2ffd7bf01
commit 8489bd1476
9 changed files with 1163 additions and 95 deletions

View file

@ -54,6 +54,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -71,7 +72,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Singleton import javax.inject.Singleton
@ -95,6 +98,7 @@ class AuthRepositoryImpl(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository, private val vaultRepository: VaultRepository,
private val userLogoutManager: UserLogoutManager, private val userLogoutManager: UserLogoutManager,
private val pushManager: PushManager,
dispatcherManager: DispatcherManager, dispatcherManager: DispatcherManager,
private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() }, private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() },
) : AuthRepository { ) : AuthRepository {
@ -117,7 +121,9 @@ class AuthRepositoryImpl(
* use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of * use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of
* these flows changes. * these flows changes.
*/ */
private val collectionScope = CoroutineScope(dispatcherManager.unconfined) private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
override var twoFactorResponse: TwoFactorRequired? = null override var twoFactorResponse: TwoFactorRequired? = null
@ -136,7 +142,7 @@ class AuthRepositoryImpl(
?: AuthState.Unauthenticated ?: AuthState.Unauthenticated
} }
.stateIn( .stateIn(
scope = collectionScope, scope = unconfinedScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = AuthState.Uninitialized, initialValue = AuthState.Uninitialized,
) )
@ -169,7 +175,7 @@ class AuthRepositoryImpl(
!mutableHasPendingAccountDeletionStateFlow.value !mutableHasPendingAccountDeletionStateFlow.value
} }
.stateIn( .stateIn(
scope = collectionScope, scope = unconfinedScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = authDiskSource initialValue = authDiskSource
.userState .userState
@ -199,6 +205,22 @@ class AuthRepositoryImpl(
override var hasPendingAccountAddition: Boolean override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value by mutableHasPendingAccountAdditionStateFlow::value
init {
pushManager
.syncOrgKeysFlow
.onEach {
val userId = activeUserId ?: return@onEach
refreshAccessTokenSynchronously(userId)
vaultRepository.sync()
}
.launchIn(ioScope)
pushManager
.logoutFlow
.onEach { logout() }
.launchIn(unconfinedScope)
}
override fun clearPendingAccountDeletion() { override fun clearPendingAccountDeletion() {
mutableHasPendingAccountDeletionStateFlow.value = false mutableHasPendingAccountDeletionStateFlow.value = false
} }

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -49,6 +50,7 @@ object AuthRepositoryModule {
settingsRepository: SettingsRepository, settingsRepository: SettingsRepository,
vaultRepository: VaultRepository, vaultRepository: VaultRepository,
userLogoutManager: UserLogoutManager, userLogoutManager: UserLogoutManager,
pushManager: PushManager,
): AuthRepository = AuthRepositoryImpl( ): AuthRepository = AuthRepositoryImpl(
accountsService = accountsService, accountsService = accountsService,
authRequestsService = authRequestsService, authRequestsService = authRequestsService,
@ -65,5 +67,6 @@ object AuthRepositoryModule {
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
userLogoutManager = userLogoutManager, userLogoutManager = userLogoutManager,
pushManager = pushManager,
) )
} }

View file

@ -149,6 +149,8 @@ class PushManagerImpl @Inject constructor(
SyncCipherUpsertData( SyncCipherUpsertData(
cipherId = payload.id, cipherId = payload.id,
revisionDate = payload.revisionDate, revisionDate = payload.revisionDate,
organizationId = payload.organizationId,
collectionIds = payload.collectionIds,
isUpdate = type == NotificationType.SYNC_CIPHER_UPDATE, isUpdate = type == NotificationType.SYNC_CIPHER_UPDATE,
), ),
) )

View file

@ -23,7 +23,7 @@ sealed class NotificationPayload {
@SerialName("id") val id: String, @SerialName("id") val id: String,
@SerialName("userId") override val userId: String?, @SerialName("userId") override val userId: String?,
@SerialName("organizationId") val organizationId: String?, @SerialName("organizationId") val organizationId: String?,
@SerialName("collectionIds") val collectionIds: List<String>, @SerialName("collectionIds") val collectionIds: List<String>?,
@Contextual @Contextual
@SerialName("revisionDate") val revisionDate: ZonedDateTime, @SerialName("revisionDate") val revisionDate: ZonedDateTime,
) : NotificationPayload() ) : NotificationPayload()

View file

@ -13,5 +13,7 @@ import java.time.ZonedDateTime
data class SyncCipherUpsertData( data class SyncCipherUpsertData(
val cipherId: String, val cipherId: String,
val revisionDate: ZonedDateTime, val revisionDate: ZonedDateTime,
val organizationId: String?,
val collectionIds: List<String>?,
val isUpdate: Boolean, val isUpdate: Boolean,
) )

View file

@ -19,8 +19,11 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
@ -91,11 +94,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -236,16 +241,36 @@ class VaultRepositoryImpl(
} }
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
pushManager
.fullSyncFlow
.onEach { syncIfNecessary() }
.launchIn(unconfinedScope)
pushManager
.syncCipherDeleteFlow
.onEach(::deleteCipher)
.launchIn(unconfinedScope)
pushManager pushManager
.syncCipherUpsertFlow .syncCipherUpsertFlow
.onEach(::syncCipherIfNecessary) .onEach(::syncCipherIfNecessary)
.launchIn(ioScope) .launchIn(ioScope)
pushManager
.syncSendDeleteFlow
.onEach(::deleteSend)
.launchIn(unconfinedScope)
pushManager pushManager
.syncSendUpsertFlow .syncSendUpsertFlow
.onEach(::syncSendIfNecessary) .onEach(::syncSendIfNecessary)
.launchIn(ioScope) .launchIn(ioScope)
pushManager
.syncFolderDeleteFlow
.onEach(::deleteFolder)
.launchIn(unconfinedScope)
pushManager pushManager
.syncFolderUpsertFlow .syncFolderUpsertFlow
.onEach(::syncFolderIfNecessary) .onEach(::syncFolderIfNecessary)
@ -1231,19 +1256,76 @@ class VaultRepositoryImpl(
.onEach { mutableSendDataStateFlow.value = it } .onEach { mutableSendDataStateFlow.value = it }
//region Push notification helpers //region Push notification helpers
/**
* Deletes the cipher specified by [syncCipherDeleteData] from disk.
*/
private suspend fun deleteCipher(syncCipherDeleteData: SyncCipherDeleteData) {
val userId = activeUserId ?: return
val cipherId = syncCipherDeleteData.cipherId
vaultDiskSource.deleteCipher(
userId = userId,
cipherId = cipherId,
)
}
/** /**
* Syncs an individual cipher contained in [syncCipherUpsertData] to disk if certain criteria * Syncs an individual cipher contained in [syncCipherUpsertData] to disk if certain criteria
* are met. If the resource cannot be found cloud-side, and it was updated, delete it from disk * are met. If the resource cannot be found cloud-side, and it was updated, delete it from disk
* for now. * for now.
*/ */
@Suppress("ReturnCount")
private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) { private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) {
val userId = activeUserId ?: return val userId = activeUserId ?: return
// TODO Handle other filtering logic including revision date comparison. This will still be
// handled as part of BIT-1547.
val cipherId = syncCipherUpsertData.cipherId val cipherId = syncCipherUpsertData.cipherId
val organizationId = syncCipherUpsertData.organizationId
val collectionIds = syncCipherUpsertData.collectionIds
val revisionDate = syncCipherUpsertData.revisionDate
val isUpdate = syncCipherUpsertData.isUpdate val isUpdate = syncCipherUpsertData.isUpdate
val localCipher = ciphersStateFlow
.mapNotNull { it.data }
.first()
.find { it.id == cipherId }
// Return if local cipher is more recent
if (localCipher != null &&
localCipher.revisionDate.epochSecond > revisionDate.toEpochSecond()
) {
return
}
var shouldUpdate: Boolean
val shouldCheckCollections: Boolean
when {
isUpdate -> {
shouldUpdate = localCipher != null
shouldCheckCollections = true
}
collectionIds == null || organizationId == null -> {
shouldUpdate = localCipher == null
shouldCheckCollections = false
}
else -> {
shouldUpdate = false
shouldCheckCollections = true
}
}
if (!shouldUpdate && shouldCheckCollections && organizationId != null) {
// Check if there are any collections in common
shouldUpdate = collectionsStateFlow
.mapNotNull { it.data }
.first()
.mapNotNull { it.id }
.any { collectionIds?.contains(it) == true } == true
}
if (!shouldUpdate) return
ciphersService ciphersService
.getCipher(cipherId) .getCipher(cipherId)
.fold( .fold(
@ -1259,6 +1341,19 @@ class VaultRepositoryImpl(
) )
} }
/**
* Deletes the send specified by [syncSendDeleteData] from disk.
*/
private suspend fun deleteSend(syncSendDeleteData: SyncSendDeleteData) {
val userId = activeUserId ?: return
val sendId = syncSendDeleteData.sendId
vaultDiskSource.deleteSend(
userId = userId,
sendId = sendId,
)
}
/** /**
* Syncs an individual send contained in [syncSendUpsertData] to disk if certain criteria are * Syncs an individual send contained in [syncSendUpsertData] to disk if certain criteria are
* met. If the resource cannot be found cloud-side, and it was updated, delete it from disk for * met. If the resource cannot be found cloud-side, and it was updated, delete it from disk for
@ -1266,12 +1361,22 @@ class VaultRepositoryImpl(
*/ */
private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) { private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) {
val userId = activeUserId ?: return val userId = activeUserId ?: return
// TODO Handle other filtering logic including revision date comparison. This will still be
// handled as part of BIT-1547.
val sendId = syncSendUpsertData.sendId val sendId = syncSendUpsertData.sendId
val isUpdate = syncSendUpsertData.isUpdate val isUpdate = syncSendUpsertData.isUpdate
val revisionDate = syncSendUpsertData.revisionDate
val localSend = sendDataStateFlow
.mapNotNull { it.data }
.first()
.sendViewList
.find { it.id == sendId }
val isValidCreate = !isUpdate && localSend == null
val isValidUpdate = isUpdate &&
localSend != null &&
localSend.revisionDate.epochSecond < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
sendsService sendsService
.getSend(sendId) .getSend(sendId)
.fold( .fold(
@ -1287,17 +1392,44 @@ class VaultRepositoryImpl(
) )
} }
/**
* Deletes the folder specified by [syncFolderDeleteData] from disk.
*/
private suspend fun deleteFolder(syncFolderDeleteData: SyncFolderDeleteData) {
val userId = activeUserId ?: return
val folderId = syncFolderDeleteData.folderId
clearFolderIdFromCiphers(
folderId = folderId,
userId = userId,
)
vaultDiskSource.deleteFolder(
folderId = folderId,
userId = userId,
)
}
/** /**
* Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria * Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria
* are met. * are met.
*/ */
private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) { private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) {
val userId = activeUserId ?: return val userId = activeUserId ?: return
// TODO Handle other filtering logic including revision date comparison. This will still be
// handled as part of BIT-1547.
val folderId = syncFolderUpsertData.folderId val folderId = syncFolderUpsertData.folderId
val isUpdate = syncFolderUpsertData.isUpdate
val revisionDate = syncFolderUpsertData.revisionDate
val localFolder = foldersStateFlow
.mapNotNull { it.data }
.first()
.find { it.id == folderId }
val isValidCreate = !isUpdate && localFolder == null
val isValidUpdate = isUpdate &&
localFolder != null &&
localFolder.revisionDate.epochSecond < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
folderService folderService
.getFolder(folderId) .getFolder(folderId)
.onSuccess { vaultDiskSource.saveFolder(userId, it) } .onSuccess { vaultDiskSource.saveFolder(userId, it) }

View file

@ -64,10 +64,12 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
@ -172,6 +174,13 @@ class AuthRepositoryTest {
every { logout(any()) } just runs every { logout(any()) } just runs
} }
private val mutableLogoutFlow = bufferedMutableSharedFlow<Unit>()
private val mutableSyncOrgKeysFlow = bufferedMutableSharedFlow<Unit>()
private val pushManager: PushManager = mockk {
every { logoutFlow } returns mutableLogoutFlow
every { syncOrgKeysFlow } returns mutableSyncOrgKeysFlow
}
private var elapsedRealtimeMillis = 123456789L private var elapsedRealtimeMillis = 123456789L
private val repository = AuthRepositoryImpl( private val repository = AuthRepositoryImpl(
@ -190,6 +199,7 @@ class AuthRepositoryTest {
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
userLogoutManager = userLogoutManager, userLogoutManager = userLogoutManager,
dispatcherManager = dispatcherManager, dispatcherManager = dispatcherManager,
pushManager = pushManager,
elapsedRealtimeMillisProvider = { elapsedRealtimeMillis }, elapsedRealtimeMillisProvider = { elapsedRealtimeMillis },
) )
@ -2770,6 +2780,43 @@ class AuthRepositoryTest {
) )
} }
@Suppress("MaxLineLength")
@Test
fun `logOutFlow emission for action account should call logout on the UserLogoutManager and clear the user's in memory vault data`() {
val userId = USER_ID_1
fakeAuthDiskSource.userState = MULTI_USER_STATE
mutableLogoutFlow.tryEmit(Unit)
coVerify(exactly = 1) {
userLogoutManager.logout(userId = userId)
vaultRepository.clearUnlockedData()
}
}
@Test
fun `syncOrgKeysFlow emissions should refresh access token and sync`() {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
coEvery {
identityService.refreshTokenSynchronously(REFRESH_TOKEN)
} returns REFRESH_TOKEN_RESPONSE_JSON.asSuccess()
every {
REFRESH_TOKEN_RESPONSE_JSON.toUserStateJson(
userId = USER_ID_1,
previousUserState = SINGLE_USER_STATE_1,
)
} returns SINGLE_USER_STATE_1
coEvery { vaultRepository.sync() } just runs
mutableSyncOrgKeysFlow.tryEmit(Unit)
coVerify(exactly = 1) {
identityService.refreshTokenSynchronously(REFRESH_TOKEN)
vaultRepository.sync()
}
}
companion object { companion object {
private const val UNIQUE_APP_ID = "testUniqueAppId" private const val UNIQUE_APP_ID = "testUniqueAppId"
private const val EMAIL = "test@bitwarden.com" private const val EMAIL = "test@bitwarden.com"

View file

@ -184,6 +184,8 @@ class PushManagerTest {
assertEquals( assertEquals(
SyncCipherUpsertData( SyncCipherUpsertData(
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321", cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
organizationId = "6a41d965-ed95-4eae-98c3-5f1ec609c2c1",
collectionIds = listOf(),
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"), revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = false, isUpdate = false,
), ),
@ -214,6 +216,8 @@ class PushManagerTest {
assertEquals( assertEquals(
SyncCipherUpsertData( SyncCipherUpsertData(
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321", cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
organizationId = "6a41d965-ed95-4eae-98c3-5f1ec609c2c1",
collectionIds = listOf(),
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"), revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = true, isUpdate = true,
), ),