From 16fce43739484e732f33bada4454a38be56375b4 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 20 Jun 2024 14:45:13 -0500 Subject: [PATCH] BIT-2418: Add the OrganizationEventManager (#3330) --- .../x8bit/bitwarden/BitwardenApplication.kt | 4 + .../data/auth/repository/AuthRepository.kt | 6 + .../auth/repository/AuthRepositoryImpl.kt | 3 + .../manager/di/PlatformManagerModule.kt | 22 ++ .../manager/event/OrganizationEventManager.kt | 13 ++ .../event/OrganizationEventManagerImpl.kt | 112 +++++++++ .../auth/repository/AuthRepositoryTest.kt | 15 ++ .../event/OrganizationEventManagerTest.kt | 218 ++++++++++++++++++ 8 files changed, 393 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerImpl.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/BitwardenApplication.kt b/app/src/main/java/com/x8bit/bitwarden/BitwardenApplication.kt index e66660e7d..9a6073392 100644 --- a/app/src/main/java/com/x8bit/bitwarden/BitwardenApplication.kt +++ b/app/src/main/java/com/x8bit/bitwarden/BitwardenApplication.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager +import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -24,4 +25,7 @@ class BitwardenApplication : Application() { @Inject lateinit var authRequestNotificationManager: AuthRequestNotificationManager + + @Inject + lateinit var organizationEventManager: OrganizationEventManager } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index dfb09041c..82ba6f7fa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -30,6 +30,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -123,6 +124,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ val passwordResetReason: ForcePasswordResetReason? + /** + * The organization for the active user. + */ + val organizations: List + /** * Clears the pending deletion state that occurs when the an account is successfully deleted. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 1c34b9092..5c272e09e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -312,6 +312,9 @@ class AuthRepositoryImpl( ?.profile ?.forcePasswordResetReason + override val organizations: List + get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty() + init { pushManager .syncOrgKeysFlow diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index 93daac961..5ebb0a76f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -4,12 +4,14 @@ import android.app.Application import android.content.Context import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors +import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl @@ -35,6 +37,8 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManagerImpl +import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager +import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository @@ -62,6 +66,24 @@ object PlatformManagerModule { fun provideAppForegroundManager(): AppForegroundManager = AppForegroundManagerImpl() + @Provides + @Singleton + fun provideOrganizationEventManager( + authRepository: AuthRepository, + vaultRepository: VaultRepository, + clock: Clock, + dispatcherManager: DispatcherManager, + eventDiskSource: EventDiskSource, + eventService: EventService, + ): OrganizationEventManager = OrganizationEventManagerImpl( + authRepository = authRepository, + vaultRepository = vaultRepository, + clock = clock, + dispatcherManager = dispatcherManager, + eventDiskSource = eventDiskSource, + eventService = eventService, + ) + @Provides @Singleton fun providesCipherMatchingManager( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManager.kt new file mode 100644 index 000000000..17036b55d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManager.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.platform.manager.event + +import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType + +/** + * A manager for tracking events. + */ +interface OrganizationEventManager { + /** + * Tracks a specific event to be uploaded at a different time. + */ + suspend fun trackEvent(eventType: OrganizationEventType, cipherId: String? = null) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerImpl.kt new file mode 100644 index 000000000..787f04f9d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerImpl.kt @@ -0,0 +1,112 @@ +package com.x8bit.bitwarden.data.platform.manager.event + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.AuthState +import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource +import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent +import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.ZonedDateTime + +/** + * The amount of time to delay before attempting the first upload events after the app is + * foregrounded. + */ +private const val UPLOAD_DELAY_INITIAL_MS: Long = 120_000L + +/** + * The amount of time to delay before a subsequent attempts to upload events after the first one. + */ +private const val UPLOAD_DELAY_INTERVAL_MS: Long = 300_000L + +/** + * Default implementation of [OrganizationEventManager]. + */ +@Suppress("LongParameterList") +class OrganizationEventManagerImpl( + private val clock: Clock, + private val authRepository: AuthRepository, + private val vaultRepository: VaultRepository, + private val eventDiskSource: EventDiskSource, + private val eventService: EventService, + dispatcherManager: DispatcherManager, + processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), +) : OrganizationEventManager { + private val ioScope = CoroutineScope(dispatcherManager.io) + private var job: Job = Job().apply { complete() } + + init { + processLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) = start() + + override fun onStop(owner: LifecycleOwner) = stop() + }, + ) + } + + @Suppress("ReturnCount") + override suspend fun trackEvent(eventType: OrganizationEventType, cipherId: String?) { + val userId = authRepository.activeUserId ?: return + if (authRepository.authStateFlow.value !is AuthState.Authenticated) return + val organizations = authRepository.organizations.filter { it.shouldUseEvents } + if (organizations.none()) return + cipherId?.let { id -> + val cipherOrganizationId = vaultRepository + .getVaultItemStateFlow(itemId = id) + .first { it.data != null } + .data + ?.organizationId + ?: return + if (organizations.none { it.id == cipherOrganizationId }) return + } + eventDiskSource.addOrganizationEvent( + userId = userId, + event = OrganizationEvent( + type = eventType, + cipherId = cipherId, + date = ZonedDateTime.now(clock), + ), + ) + } + + private suspend fun uploadEvents() { + val userId = authRepository.activeUserId ?: return + val events = eventDiskSource + .getOrganizationEvents(userId = userId) + .takeUnless { it.isEmpty() } + ?: return + eventService + .sendOrganizationEvents(events = events) + .onSuccess { eventDiskSource.deleteOrganizationEvents(userId = userId) } + } + + private fun start() { + job.cancel() + job = ioScope.launch { + delay(timeMillis = UPLOAD_DELAY_INITIAL_MS) + uploadEvents() + while (coroutineContext.isActive) { + delay(timeMillis = UPLOAD_DELAY_INTERVAL_MS) + uploadEvents() + } + } + } + + private fun stop() { + job.cancel() + ioScope.launch { uploadEvents() } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index dc4abf2e2..5c8234f99 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -571,6 +571,21 @@ class AuthRepositoryTest { ) } + @Test + fun `organizations should return an empty list when there is no active user`() = runTest { + assertEquals(emptyList(), repository.organizations) + } + + @Test + fun `organizations should pull from the organizations in the AuthDiskSource`() = runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeOrganizations( + userId = USER_ID_1, + organizations = ORGANIZATIONS, + ) + assertEquals(ORGANIZATIONS, repository.organizations) + } + @Test fun `clear Pending Account Deletion should unblock userState updates`() = runTest { val masterPassword = "hello world" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerTest.kt new file mode 100644 index 000000000..cbed10e51 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/event/OrganizationEventManagerTest.kt @@ -0,0 +1,218 @@ +package com.x8bit.bitwarden.data.platform.manager.event + +import com.bitwarden.vault.CipherView +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.AuthState +import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource +import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent +import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService +import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.platform.util.asSuccess +import com.x8bit.bitwarden.data.util.FakeLifecycleOwner +import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime + +class OrganizationEventManagerTest { + + private val fakeLifecycleOwner = FakeLifecycleOwner() + private val fixedClock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + private val dispatcher = StandardTestDispatcher() + private val fakeDispatcherManager = FakeDispatcherManager(io = dispatcher) + private val mutableAuthStateFlow = MutableStateFlow(value = AuthState.Uninitialized) + private val authRepository = mockk { + every { activeUserId } returns USER_ID + every { authStateFlow } returns mutableAuthStateFlow + every { organizations } returns emptyList() + } + private val mutableVaultItemStateFlow = MutableStateFlow>( + value = DataState.Loading + ) + private val vaultRepository = mockk { + every { getVaultItemStateFlow(itemId = any()) } returns mutableVaultItemStateFlow + } + private val eventService = mockk() + private val eventDiskSource = mockk { + coEvery { addOrganizationEvent(userId = any(), event = any()) } just runs + } + + private val organizationEventManager: OrganizationEventManager = OrganizationEventManagerImpl( + processLifecycleOwner = fakeLifecycleOwner, + clock = fixedClock, + dispatcherManager = fakeDispatcherManager, + authRepository = authRepository, + vaultRepository = vaultRepository, + eventService = eventService, + eventDiskSource = eventDiskSource, + ) + + @Test + fun `onLifecycleStart should upload events after 2 minutes and again after 5 more minutes`() = + runTest { + val organizationEvent = OrganizationEvent( + type = OrganizationEventType.CIPHER_UPDATED, + cipherId = CIPHER_ID, + date = ZonedDateTime.now(fixedClock), + ) + val events = listOf(organizationEvent) + coEvery { eventDiskSource.getOrganizationEvents(userId = USER_ID) } returns events + coEvery { + eventService.sendOrganizationEvents(events = events) + } returns Unit.asSuccess() + coEvery { eventDiskSource.deleteOrganizationEvents(userId = USER_ID) } just runs + + fakeLifecycleOwner.lifecycle.dispatchOnStart() + + dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 120_000L) + coVerify(exactly = 1) { + eventDiskSource.getOrganizationEvents(userId = USER_ID) + eventService.sendOrganizationEvents(events = events) + eventDiskSource.deleteOrganizationEvents(userId = USER_ID) + } + + dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 300_000L) + coVerify(exactly = 2) { + eventDiskSource.getOrganizationEvents(userId = USER_ID) + eventService.sendOrganizationEvents(events = events) + eventDiskSource.deleteOrganizationEvents(userId = USER_ID) + } + } + + @Test + fun `onLifecycleStop should upload events immediately`() = runTest { + val organizationEvent = OrganizationEvent( + type = OrganizationEventType.CIPHER_UPDATED, + cipherId = CIPHER_ID, + date = ZonedDateTime.now(fixedClock), + ) + val events = listOf(organizationEvent) + coEvery { eventDiskSource.getOrganizationEvents(userId = USER_ID) } returns events + coEvery { eventService.sendOrganizationEvents(events = events) } returns Unit.asSuccess() + coEvery { eventDiskSource.deleteOrganizationEvents(userId = USER_ID) } just runs + + fakeLifecycleOwner.lifecycle.dispatchOnStop() + + dispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 120_000L) + coVerify(exactly = 1) { + eventDiskSource.getOrganizationEvents(userId = USER_ID) + eventService.sendOrganizationEvents(events = events) + eventDiskSource.deleteOrganizationEvents(userId = USER_ID) + } + } + + @Test + fun `trackEvent should do nothing if there is no active user`() = runTest { + every { authRepository.activeUserId } returns null + + organizationEventManager.trackEvent( + eventType = OrganizationEventType.CIPHER_UPDATED, + cipherId = CIPHER_ID, + ) + + coVerify(exactly = 0) { + eventDiskSource.addOrganizationEvent(userId = any(), event = any()) + } + } + + @Test + fun `trackEvent should do nothing if the active user is not authenticated`() = runTest { + organizationEventManager.trackEvent( + eventType = OrganizationEventType.CIPHER_UPDATED, + cipherId = CIPHER_ID, + ) + + coVerify(exactly = 0) { + eventDiskSource.addOrganizationEvent(userId = any(), event = any()) + } + } + + @Test + fun `trackEvent should do nothing if the active user has no organizations that use events`() = + runTest { + mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = "access-token") + val organization = createMockOrganization(number = 1) + every { authRepository.organizations } returns listOf(organization) + + organizationEventManager.trackEvent( + eventType = OrganizationEventType.CIPHER_UPDATED, + cipherId = CIPHER_ID, + ) + + coVerify(exactly = 0) { + eventDiskSource.addOrganizationEvent(userId = any(), event = any()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `trackEvent should do nothing if the cipher does not belong to an organization that uses events`() = + runTest { + mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = "access-token") + val organization = createMockOrganization(number = 1).copy(shouldUseEvents = true) + every { authRepository.organizations } returns listOf(organization) + val cipherView = createMockCipherView(number = 1) + mutableVaultItemStateFlow.value = DataState.Loaded(data = cipherView) + + organizationEventManager.trackEvent( + eventType = OrganizationEventType.CIPHER_UPDATED, + cipherId = CIPHER_ID, + ) + + coVerify(exactly = 0) { + eventDiskSource.addOrganizationEvent(userId = any(), event = any()) + } + } + + @Test + fun `trackEvent should add the event to disk if the ciphers organization allows it`() = + runTest { + mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = "access-token") + val organization = createMockOrganization(number = 1).copy( + id = "mockOrganizationId-1", + shouldUseEvents = true, + ) + every { authRepository.organizations } returns listOf(organization) + val cipherView = createMockCipherView(number = 1) + mutableVaultItemStateFlow.value = DataState.Loaded(data = cipherView) + val eventType = OrganizationEventType.CIPHER_UPDATED + + organizationEventManager.trackEvent( + eventType = eventType, + cipherId = CIPHER_ID, + ) + + coVerify(exactly = 1) { + eventDiskSource.addOrganizationEvent( + userId = USER_ID, + event = OrganizationEvent( + type = eventType, + cipherId = CIPHER_ID, + date = ZonedDateTime.now(fixedClock), + ), + ) + } + } +} + +private const val CIPHER_ID: String = "mockId-1" +private const val USER_ID: String = "user-id"