BIT-2418: Add the OrganizationEventManager (#3330)

This commit is contained in:
David Perez 2024-06-20 14:45:13 -05:00 committed by GitHub
parent 170db5077d
commit 16fce43739
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 393 additions and 0 deletions

View file

@ -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
}

View file

@ -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<SyncResponseJson.Profile.Organization>
/**
* Clears the pending deletion state that occurs when the an account is successfully deleted.
*/

View file

@ -312,6 +312,9 @@ class AuthRepositoryImpl(
?.profile
?.forcePasswordResetReason
override val organizations: List<SyncResponseJson.Profile.Organization>
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
init {
pushManager
.syncOrgKeysFlow

View file

@ -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(

View file

@ -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)
}

View file

@ -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() }
}
}

View file

@ -571,6 +571,21 @@ class AuthRepositoryTest {
)
}
@Test
fun `organizations should return an empty list when there is no active user`() = runTest {
assertEquals(emptyList<SyncResponseJson.Profile.Organization>(), 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"

View file

@ -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<AuthState>(value = AuthState.Uninitialized)
private val authRepository = mockk<AuthRepository> {
every { activeUserId } returns USER_ID
every { authStateFlow } returns mutableAuthStateFlow
every { organizations } returns emptyList()
}
private val mutableVaultItemStateFlow = MutableStateFlow<DataState<CipherView?>>(
value = DataState.Loading
)
private val vaultRepository = mockk<VaultRepository> {
every { getVaultItemStateFlow(itemId = any()) } returns mutableVaultItemStateFlow
}
private val eventService = mockk<EventService>()
private val eventDiskSource = mockk<EventDiskSource> {
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"