Merge branch 'main' into pm-6701/email-verification-selfhost-registration

# Conflicts:
#	.github/workflows/scan.yml
#	app/build.gradle.kts
#	app/src/main/java/com/x8bit/bitwarden/BitwardenApplication.kt
#	app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt
#	app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
#	app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt
#	app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt
#	app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/Retrofits.kt
#	app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsImpl.kt
#	app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountScreen.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/createaccount/CreateAccountViewModel.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt
#	app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt
#	app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/retrofit/RetrofitsTest.kt
#	app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppForegroundManagerTest.kt
#	app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt
#	app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt
#	build.gradle.kts
#	gradle/libs.versions.toml
This commit is contained in:
André Bispo
2024-06-21 11:57:21 +01:00
37 changed files with 1365 additions and 60 deletions

View File

@@ -40,3 +40,37 @@ jobs:
base_uri: https://ast.checkmarx.net/
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
additional_params: |
--report-format sarif \
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
with:
sarif_file: cx_result.sarif
quality:
name: Quality scan
runs-on: ubuntu-22.04
needs: check-run
permissions:
contents: read
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarcloud-github-action@4006f663ecaf1f8093e8e4abb9227f6041f52216 # v2.2.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}

View File

@@ -17,6 +17,7 @@ plugins {
alias(libs.plugins.kotlinx.kover)
alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
alias(libs.plugins.sonarqube)
}
android {
@@ -276,3 +277,19 @@ afterEvaluate {
.filter { it.name.contains("Fdroid") }
.forEach { it.enabled = false }
}
sonar {
properties {
property("sonar.projectKey", "bitwarden_android")
property("sonar.organization", "bitwarden")
property("sonar.host.url", "https://sonarcloud.io")
property("sonar.sources", "app/src/main/,app/src/standard/,app/src/fdroid/")
property("sonar.tests", "app/src/test/")
}
}
tasks {
getByName("sonar") {
dependsOn("check")
}
}

View File

@@ -0,0 +1,68 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "f40d7a933b2f353d8d5b5ca619f28e24",
"entities": [
{
"tableName": "organization_events",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationEventType",
"columnName": "organization_event_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherId",
"columnName": "cipher_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_organization_events_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f40d7a933b2f353d8d5b5ca619f28e24')"
]
}
}

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

@@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
/**
* Primary access point for disk information related to event data.
*/
interface EventDiskSource {
/**
* Deletes all organization events associated with the given [userId].
*/
suspend fun deleteOrganizationEvents(userId: String)
/**
* Adds a new organization event associated with the given [userId].
*/
suspend fun addOrganizationEvent(userId: String, event: OrganizationEvent)
/**
* Retrieves all organization events associated with the given [userId].
*/
suspend fun getOrganizationEvents(userId: String): List<OrganizationEvent>
}

View File

@@ -0,0 +1,54 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* The default implementation of [EventDiskSource].
*/
class EventDiskSourceImpl(
private val organizationEventDao: OrganizationEventDao,
private val dispatcherManager: DispatcherManager,
private val json: Json,
) : EventDiskSource {
override suspend fun deleteOrganizationEvents(userId: String) {
organizationEventDao.deleteOrganizationEvents(userId = userId)
}
override suspend fun addOrganizationEvent(userId: String, event: OrganizationEvent) {
organizationEventDao.insertOrganizationEvent(
event = OrganizationEventEntity(
userId = userId,
organizationEventType = withContext(context = dispatcherManager.default) {
json.encodeToString(value = event.type)
},
cipherId = event.cipherId,
date = event.date,
),
)
}
override suspend fun getOrganizationEvents(
userId: String,
): List<OrganizationEvent> =
organizationEventDao
.getOrganizationEvents(userId = userId)
.map {
OrganizationEvent(
type = withContext(context = dispatcherManager.default) {
json.decodeFromString<OrganizationEventType>(
string = it.organizationEventType,
)
},
cipherId = it.cipherId,
date = it.date,
)
}
}

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
/**
* Provides methods for inserting, retrieving, and deleting events from the database using the
* [OrganizationEventEntity].
*/
@Dao
interface OrganizationEventDao {
/**
* Deletes all the stored events associated with the given [userId]. This will return the
* number of rows deleted by this query.
*/
@Query("DELETE FROM organization_events WHERE user_id = :userId")
suspend fun deleteOrganizationEvents(userId: String): Int
/**
* Retrieves all events from the database for a given [userId].
*/
@Query("SELECT * FROM organization_events WHERE user_id = :userId")
suspend fun getOrganizationEvents(userId: String): List<OrganizationEventEntity>
/**
* Inserts an event into the database.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrganizationEvent(event: OrganizationEventEntity)
}

View File

@@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
/**
* Room database for storing any persisted data for platform data.
*/
@Database(
entities = [
OrganizationEventEntity::class,
],
version = 1,
exportSchema = true,
)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class PlatformDatabase : RoomDatabase() {
/**
* Provides the DAO for accessing organization event data.
*/
abstract fun organizationEventDao(): OrganizationEventDao
}

View File

@@ -3,21 +3,28 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.di
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.room.Room
import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences
import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
import com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigratorImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorage
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigratorImpl
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -44,6 +51,38 @@ object PlatformDiskModule {
json = json,
)
@Provides
@Singleton
fun provideEventDatabase(app: Application): PlatformDatabase =
Room
.databaseBuilder(
context = app,
klass = PlatformDatabase::class.java,
name = "platform_database",
)
.fallbackToDestructiveMigration()
.addTypeConverter(ZonedDateTimeTypeConverter())
.build()
@Provides
@Singleton
fun provideOrganizationEventDao(
database: PlatformDatabase,
): OrganizationEventDao = database.organizationEventDao()
@Provides
@Singleton
fun provideEventDiskSource(
organizationEventDao: OrganizationEventDao,
dispatcherManager: DispatcherManager,
json: Json,
): EventDiskSource =
EventDiskSourceImpl(
organizationEventDao = organizationEventDao,
dispatcherManager = dispatcherManager,
json = json,
)
@Provides
@Singleton
fun provideLegacySecureStorage(

View File

@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.ZonedDateTime
/**
* Entity representing an organization event in the database.
*/
@Entity(tableName = "organization_events")
data class OrganizationEventEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
val id: Int = 0,
@ColumnInfo(name = "user_id", index = true)
val userId: String,
@ColumnInfo(name = "organization_event_type")
val organizationEventType: String,
@ColumnInfo(name = "cipher_id")
val cipherId: String?,
@ColumnInfo(name = "date")
val date: ZonedDateTime,
)

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.datasource.network.api
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
import retrofit2.http.Body
import retrofit2.http.POST
/**
* This interface defines the API service for posting event data.
*/
interface EventApi {
@POST("/collect")
suspend fun collectOrganizationEvents(@Body events: List<OrganizationEvent>): Result<Unit>
}

View File

@@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsIm
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.ZonedDateTimeSerializer
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushServiceImpl
import dagger.Module
@@ -36,6 +38,14 @@ object PlatformNetworkModule {
retrofits: Retrofits,
): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create())
@Provides
@Singleton
fun providesEventService(
retrofits: Retrofits,
): EventService = EventServiceImpl(
eventApi = retrofits.authenticatedEventsRetrofit.create(),
)
@Provides
@Singleton
fun providePushService(

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.datasource.network.model
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* Represents an individual organization event including the type and time.
*/
@Serializable
data class OrganizationEvent(
@SerialName("type") val type: OrganizationEventType,
@SerialName("cipherId") val cipherId: String?,
@SerialName("date") @Contextual val date: ZonedDateTime,
)

View File

@@ -14,6 +14,13 @@ interface Retrofits {
*/
val authenticatedApiRetrofit: Retrofit
/**
* Allows access to "/events" calls that must be authenticated.
*
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
*/
val authenticatedEventsRetrofit: Retrofit
/**
* Allows access to "/api" calls that do not require authentication.
*

View File

@@ -36,6 +36,12 @@ class RetrofitsImpl(
)
}
override val authenticatedEventsRetrofit: Retrofit by lazy {
createAuthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.eventsInterceptor,
)
}
//endregion Authenticated Retrofits
//region Unauthenticated Retrofits

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.datasource.network.service
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
/**
* Provides an API for submitting events.
*/
interface EventService {
/**
* Attempts to submit all of the given organizations events.
*/
suspend fun sendOrganizationEvents(events: List<OrganizationEvent>): Result<Unit>
}

View File

@@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.platform.datasource.network.service
import com.x8bit.bitwarden.data.platform.datasource.network.api.EventApi
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
/**
* The default implementation of the [EventService].
*/
class EventServiceImpl(
private val eventApi: EventApi,
) : EventService {
override suspend fun sendOrganizationEvents(
events: List<OrganizationEvent>,
): Result<Unit> = eventApi.collectOrganizationEvents(events = events)
}

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.
*/
fun trackEvent(eventType: OrganizationEventType, cipherId: String? = null)
}

View File

@@ -0,0 +1,115 @@
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 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
ioScope.launch {
cipherId?.let { id ->
val cipherOrganizationId = vaultRepository
.getVaultItemStateFlow(itemId = id)
.first { it.data != null }
.data
?.organizationId
?: return@launch
if (organizations.none { it.id == cipherOrganizationId }) return@launch
}
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

@@ -0,0 +1,134 @@
package com.x8bit.bitwarden.data.platform.manager.model
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Representation of events used for organization tracking.
*/
@Serializable(OrganizationEventTypeSerializer::class)
enum class OrganizationEventType {
@SerialName("1000")
USER_LOGGED_IN,
@SerialName("1001")
USER_CHANGED_PASSWORD,
@SerialName("1002")
USER_UPDATED_2FA,
@SerialName("1003")
USER_DISABLED_2FA,
@SerialName("1004")
USER_RECOVERED_2FA,
@SerialName("1005")
USER_FAILED_LOGIN,
@SerialName("1006")
USER_FAILED_LOGIN_2FA,
@SerialName("1007")
USER_CLIENT_EXPORTED_VAULT,
@SerialName("1100")
CIPHER_CREATED,
@SerialName("1101")
CIPHER_UPDATED,
@SerialName("1102")
CIPHER_DELETED,
@SerialName("1103")
CIPHER_ATTACHMENT_CREATED,
@SerialName("1104")
CIPHER_ATTACHMENT_DELETED,
@SerialName("1105")
CIPHER_SHARED,
@SerialName("1106")
CIPHER_UPDATED_COLLECTIONS,
@SerialName("1107")
CIPHER_CLIENT_VIEWED,
@SerialName("1108")
CIPHER_CLIENT_TOGGLED_PASSWORD_VISIBLE,
@SerialName("1109")
CIPHER_CLIENT_TOGGLED_HIDDEN_FIELD_VISIBLE,
@SerialName("1110")
CIPHER_CLIENT_TOGGLED_CARD_CODE_VISIBLE,
@SerialName("1111")
CIPHER_CLIENT_COPIED_PASSWORD,
@SerialName("1112")
CIPHER_CLIENT_COPIED_HIDDEN_FIELD,
@SerialName("1113")
CIPHER_CLIENT_COPIED_CARD_CODE,
@SerialName("1114")
CIPHER_CLIENT_AUTO_FILLED,
@SerialName("1115")
CIPHER_SOFT_DELETED,
@SerialName("1116")
CIPHER_RESTORED,
@SerialName("1117")
CIPHER_CLIENT_TOGGLED_CARD_NUMBER_VISIBLE,
@SerialName("1300")
COLLECTION_CREATED,
@SerialName("1301")
COLLECTION_UPDATED,
@SerialName("1302")
COLLECTION_DELETED,
@SerialName("1400")
GROUP_CREATED,
@SerialName("1401")
GROUP_UPDATED,
@SerialName("1402")
GROUP_DELETED,
@SerialName("1500")
ORGANIZATION_USER_INVITED,
@SerialName("1501")
ORGANIZATION_USER_CONFIRMED,
@SerialName("1502")
ORGANIZATION_USER_UPDATED,
@SerialName("1503")
ORGANIZATION_USER_REMOVED,
@SerialName("1504")
ORGANIZATION_USER_UPDATED_GROUPS,
@SerialName("1600")
ORGANIZATION_UPDATED,
@SerialName("1601")
ORGANIZATION_PURGED_VAULT,
}
@Keep
private class OrganizationEventTypeSerializer : BaseEnumeratedIntSerializer<OrganizationEventType>(
values = OrganizationEventType.entries.toTypedArray(),
)

View File

@@ -0,0 +1,106 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
/**
* Draws a password indicator that displays password strength based on the given [state].
*/
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
@Composable
fun PasswordStrengthIndicator(
modifier: Modifier = Modifier,
state: PasswordStrengthState,
) {
val widthPercent by animateFloatAsState(
targetValue = when (state) {
PasswordStrengthState.NONE -> 0f
PasswordStrengthState.WEAK_1 -> .25f
PasswordStrengthState.WEAK_2 -> .5f
PasswordStrengthState.WEAK_3 -> .66f
PasswordStrengthState.GOOD -> .82f
PasswordStrengthState.STRONG -> 1f
},
label = "Width Percent State",
)
val indicatorColor = when (state) {
PasswordStrengthState.NONE -> MaterialTheme.colorScheme.error
PasswordStrengthState.WEAK_1 -> MaterialTheme.colorScheme.error
PasswordStrengthState.WEAK_2 -> MaterialTheme.colorScheme.error
PasswordStrengthState.WEAK_3 -> LocalNonMaterialColors.current.passwordWeak
PasswordStrengthState.GOOD -> MaterialTheme.colorScheme.primary
PasswordStrengthState.STRONG -> LocalNonMaterialColors.current.passwordStrong
}
val animatedIndicatorColor by animateColorAsState(
targetValue = indicatorColor,
label = "Indicator Color State",
)
val label = when (state) {
PasswordStrengthState.NONE -> "".asText()
PasswordStrengthState.WEAK_1 -> R.string.weak.asText()
PasswordStrengthState.WEAK_2 -> R.string.weak.asText()
PasswordStrengthState.WEAK_3 -> R.string.weak.asText()
PasswordStrengthState.GOOD -> R.string.good.asText()
PasswordStrengthState.STRONG -> R.string.strong.asText()
}
Column(
modifier = modifier,
) {
Box(
Modifier
.fillMaxWidth()
.height(4.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.graphicsLayer {
transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f)
scaleX = widthPercent
}
.drawBehind {
drawRect(animatedIndicatorColor)
},
)
}
Spacer(Modifier.height(4.dp))
Text(
text = label(),
style = MaterialTheme.typography.labelSmall,
color = indicatorColor,
)
}
}
/**
* Models various levels of password strength that can be displayed by [PasswordStrengthIndicator].
*/
enum class PasswordStrengthState {
NONE,
WEAK_1,
WEAK_2,
WEAK_3,
GOOD,
STRONG,
}

View File

@@ -165,6 +165,6 @@ sealed class QrCodeScanAction {
* Checks if a string is using base32 digits.
*/
private fun String.isBase32(): Boolean {
val regex = ("^[A-Z2-7]+=*$").toRegex()
val regex = ("^[A-Za-z2-7]+=*$").toRegex()
return regex.matches(this)
}

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,142 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.FakeOrganizationEventDao
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
class EventDiskSourceTest {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val fakeOrganizationEventDao = FakeOrganizationEventDao()
private val fakeDispatcherManager = FakeDispatcherManager()
private val json = PlatformNetworkModule.providesJson()
private val eventDiskSource: EventDiskSource = EventDiskSourceImpl(
organizationEventDao = fakeOrganizationEventDao,
dispatcherManager = fakeDispatcherManager,
json = json,
)
@Test
fun `addOrganizationEvent should insert a new organization event`() = runTest {
val userId = "userId-1"
val organizationEvent = OrganizationEvent(
type = OrganizationEventType.CIPHER_DELETED,
cipherId = "cipherId-1",
date = ZonedDateTime.now(fixedClock),
)
eventDiskSource.addOrganizationEvent(
userId = userId,
event = organizationEvent,
)
assertEquals(
listOf(
OrganizationEventEntity(
id = 0,
userId = userId,
organizationEventType = "1102",
cipherId = "cipherId-1",
date = ZonedDateTime.now(fixedClock),
),
),
fakeOrganizationEventDao.storedEvents,
)
assertFalse(fakeOrganizationEventDao.isDeleteCalled)
assertTrue(fakeOrganizationEventDao.isInsertCalled)
}
@Test
fun `deleteOrganizationEvents should delete all organization events`() = runTest {
val userId = "userId-1"
fakeOrganizationEventDao.storedEvents.addAll(
listOf(
OrganizationEventEntity(
id = 1,
userId = userId,
organizationEventType = "1102",
cipherId = "cipherId-1",
date = ZonedDateTime.now(fixedClock),
),
OrganizationEventEntity(
id = 2,
userId = "userId-2",
organizationEventType = "1102",
cipherId = "cipherId-2",
date = ZonedDateTime.now(fixedClock),
),
),
)
eventDiskSource.deleteOrganizationEvents(userId = userId)
assertEquals(
listOf(
OrganizationEventEntity(
id = 2,
userId = "userId-2",
organizationEventType = "1102",
cipherId = "cipherId-2",
date = ZonedDateTime.now(fixedClock),
),
),
fakeOrganizationEventDao.storedEvents,
)
assertTrue(fakeOrganizationEventDao.isDeleteCalled)
assertFalse(fakeOrganizationEventDao.isInsertCalled)
}
@Test
fun `getOrganizationEvents should retrieve the correct organization events`() = runTest {
val userId = "userId-1"
fakeOrganizationEventDao.storedEvents.addAll(
listOf(
OrganizationEventEntity(
id = 1,
userId = userId,
organizationEventType = "1102",
cipherId = "cipherId-1",
date = ZonedDateTime.now(fixedClock),
),
OrganizationEventEntity(
id = 2,
userId = "userId-2",
organizationEventType = "1102",
cipherId = "cipherId-2",
date = ZonedDateTime.now(fixedClock),
),
),
)
val result = eventDiskSource.getOrganizationEvents(userId = userId)
assertEquals(
listOf(
OrganizationEvent(
type = OrganizationEventType.CIPHER_DELETED,
cipherId = "cipherId-1",
date = ZonedDateTime.now(fixedClock),
),
),
result,
)
assertFalse(fakeOrganizationEventDao.isDeleteCalled)
assertFalse(fakeOrganizationEventDao.isInsertCalled)
}
}

View File

@@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.dao
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
class FakeOrganizationEventDao : OrganizationEventDao {
val storedEvents = mutableListOf<OrganizationEventEntity>()
var isDeleteCalled = false
var isInsertCalled = false
override suspend fun deleteOrganizationEvents(userId: String): Int {
val count = storedEvents.count { it.userId == userId }
storedEvents.removeAll { it.userId == userId }
isDeleteCalled = true
return count
}
override suspend fun getOrganizationEvents(
userId: String,
): List<OrganizationEventEntity> = storedEvents.filter { it.userId == userId }
override suspend fun insertOrganizationEvent(event: OrganizationEventEntity) {
storedEvents.add(event)
isInsertCalled = true
}
}

View File

@@ -101,6 +101,36 @@ class RetrofitsTest {
assertTrue(isRefreshAuthenticatorCalled)
}
@Test
fun `authenticatedEventsRetrofit should not invoke the RefreshAuthenticator on success`() =
runBlocking {
val testApi = retrofits
.authenticatedEventsRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertFalse(isRefreshAuthenticatorCalled)
}
@Test
fun `authenticatedEventsRetrofit should invoke the RefreshAuthenticator on 401`() =
runBlocking {
val testApi = retrofits
.authenticatedEventsRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setResponseCode(401).setBody("""{}"""))
testApi.test()
assertTrue(isRefreshAuthenticatorCalled)
}
@Test
fun `unauthenticatedApiRetrofit should not invoke the RefreshAuthenticator`() = runBlocking {
val testApi = retrofits
@@ -133,6 +163,24 @@ class RetrofitsTest {
assertFalse(isEventsInterceptorCalled)
}
@Test
fun `authenticatedEventsRetrofit should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits
.authenticatedEventsRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertTrue(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isheadersInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertTrue(isEventsInterceptorCalled)
}
@Test
fun `unauthenticatedApiRetrofit should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits

View File

@@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.platform.datasource.network.service
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.platform.datasource.network.api.EventApi
import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import retrofit2.create
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
class EventServiceTest : BaseServiceTest() {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val eventApi: EventApi = retrofit.create()
private val eventService: EventService = EventServiceImpl(
eventApi = eventApi,
)
@Test
fun `sendOrganizationEvents should return the correct response`() = runTest {
server.enqueue(MockResponse())
val result = eventService.sendOrganizationEvents(
events = listOf(
OrganizationEvent(
type = OrganizationEventType.CIPHER_CREATED,
cipherId = "cipher-id",
date = ZonedDateTime.now(fixedClock),
),
),
)
assertEquals(Unit, result.getOrThrow())
}
}

View File

@@ -1,11 +1,8 @@
package com.x8bit.bitwarden.data.platform.manager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.util.FakeLifecycleOwner
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -45,43 +42,3 @@ class AppForegroundManagerTest {
}
}
}
private class FakeLifecycle(
private val lifecycleOwner: LifecycleOwner,
) : Lifecycle() {
private val observers = mutableSetOf<DefaultLifecycleObserver>()
override var currentState: State = State.INITIALIZED
override fun addObserver(observer: LifecycleObserver) {
observers += (observer as DefaultLifecycleObserver)
}
override fun removeObserver(observer: LifecycleObserver) {
observers -= (observer as DefaultLifecycleObserver)
}
/**
* Triggers [DefaultLifecycleObserver.onStart] calls for each registered observer.
*/
fun dispatchOnStart() {
currentState = State.STARTED
observers.forEach { observer ->
observer.onStart(lifecycleOwner)
}
}
/**
* Triggers [DefaultLifecycleObserver.onStop] calls for each registered observer.
*/
fun dispatchOnStop() {
currentState = State.CREATED
observers.forEach { observer ->
observer.onStop(lifecycleOwner)
}
}
}
private class FakeLifecycleOwner : LifecycleOwner {
override val lifecycle: FakeLifecycle = FakeLifecycle(this)
}

View File

@@ -0,0 +1,216 @@
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`() {
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`() {
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`() {
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`() {
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`() {
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,
)
dispatcher.scheduler.runCurrent()
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"

View File

@@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.util
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
/**
* A fake implementation of [LifecycleOwner] and [Lifecycle] for testing purposes.
*/
class FakeLifecycle(
private val lifecycleOwner: LifecycleOwner,
) : Lifecycle() {
private val observers = mutableSetOf<DefaultLifecycleObserver>()
override var currentState: State = State.INITIALIZED
override fun addObserver(observer: LifecycleObserver) {
observers += (observer as DefaultLifecycleObserver)
}
override fun removeObserver(observer: LifecycleObserver) {
observers -= (observer as DefaultLifecycleObserver)
}
/**
* Triggers [DefaultLifecycleObserver.onStart] calls for each registered observer.
*/
fun dispatchOnStart() {
currentState = State.STARTED
observers.forEach { observer ->
observer.onStart(lifecycleOwner)
}
}
/**
* Triggers [DefaultLifecycleObserver.onStop] calls for each registered observer.
*/
fun dispatchOnStop() {
currentState = State.CREATED
observers.forEach { observer ->
observer.onStop(lifecycleOwner)
}
}
}

View File

@@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.util
import androidx.lifecycle.LifecycleOwner
/**
* A fake implementation of [LifecycleOwner] for testing purposes.
*/
class FakeLifecycleOwner : LifecycleOwner {
override val lifecycle: FakeLifecycle = FakeLifecycle(lifecycleOwner = this)
}

View File

@@ -81,7 +81,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
setupMockUri()
val validCode =
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&algorithm=sha256&digits=8&period=60"
"otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha256&digits=8&period=60"
val viewModel = createViewModel()
val result = TotpCodeResult.Success(validCode)
@@ -99,8 +99,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
queryParameterNames = setOf(SECRET),
)
val validCode =
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP"
val validCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
val viewModel = createViewModel()
val result = TotpCodeResult.Success(validCode)
@@ -119,8 +118,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError
val invalidCode =
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&algorithm=sha224"
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha224"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
@@ -136,8 +134,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError
val invalidCode =
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&digits=11"
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&digits=11"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
@@ -153,8 +150,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError
val invalidCode =
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP&period=0"
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&period=0"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
@@ -169,8 +165,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError
val invalidCode =
"nototpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP"
val invalidCode = "nototpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
@@ -182,12 +177,11 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
@Test
fun `QrCodeScan should emit failure result with non base32 secret`() = runTest {
setupMockUri(secret = "JBSWY3DPEHPK3PXP1")
setupMockUri(secret = "JBSWY3dpeHPK3PXP1")
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError
val invalidCode =
"otpauth://totp/Test:me?secret=JBSWY3DPEHPK3PXP1"
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP1"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
@@ -244,7 +238,7 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
}
private fun setupMockUri(
secret: String? = "JBSWY3DPEHPK3PXP",
secret: String? = "JBSWY3dpeHPK3PXP",
algorithm: String = "SHA256",
digits: String = "8",
period: String = "60",

View File

@@ -7,4 +7,5 @@ plugins {
alias(libs.plugins.kotlinx.kover) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.sonarqube) apply false
}

View File

@@ -44,6 +44,7 @@ mockk = "1.13.11"
okhttp = "4.12.0"
retrofitBom = "2.11.0"
roboelectric = "4.12.2"
sonarqube = "5.0.0.4638"
turbine = "1.1.0"
zxcvbn4j = "1.9.0"
zxing = "3.5.3"
@@ -122,3 +123,4 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref =
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlinx-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kotlinxKover" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }