BIT-279: Adding password history data layer (#387)

This commit is contained in:
joshua-livefront 2023-12-14 12:25:35 -05:00 committed by Álison Fernandes
parent 0655f74479
commit 09fbd5d4e9
20 changed files with 693 additions and 2 deletions

View file

@ -160,6 +160,8 @@ koverReport {
"com.x8bit.bitwarden.MainActivity*",
// Empty Composables
"com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt",
// Databases
"*.database.*Database",
)
packages(
// Dependency injection

View file

@ -0,0 +1,34 @@
package com.x8bit.bitwarden.data.platform.repository.model
/**
* A local data state used for handling local data in the repository layer.
*/
sealed class LocalDataState<out T> {
/**
* Data that is being wrapped by [LocalDataState].
*/
abstract val data: T?
/**
* Loading state representing the absence of data.
*/
data object Loading : LocalDataState<Nothing>() {
override val data: Nothing? get() = null
}
/**
* Loaded state representing the availability of data.
*/
data class Loaded<T>(
override val data: T,
) : LocalDataState<T>()
/**
* Error state that may or may not have data available.
*/
data class Error<T>(
val error: Throwable,
override val data: T? = null,
) : LocalDataState<T>()
}

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
import kotlinx.coroutines.flow.Flow
/**
* Primary access point for disk information related to password history.
*/
interface PasswordHistoryDiskSource {
/**
* Retrieves all password history items from the data source as a Flow.
*/
fun getPasswordHistoriesForUser(userId: String): Flow<List<PasswordHistoryEntity>>
/**
* Inserts a generated history item into the data source.
*/
suspend fun insertPasswordHistory(passwordHistoryEntity: PasswordHistoryEntity)
/**
* Clears all password history items from the data source.
*/
suspend fun clearPasswordHistories(userId: String)
}

View file

@ -0,0 +1,27 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.PasswordHistoryDao
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
import kotlinx.coroutines.flow.Flow
/**
* Primary implementation of [PasswordHistoryDiskSource].
*/
class PasswordHistoryDiskSourceImpl(
private val passwordHistoryDao: PasswordHistoryDao,
) : PasswordHistoryDiskSource {
override fun getPasswordHistoriesForUser(userId: String): Flow<List<PasswordHistoryEntity>> {
return passwordHistoryDao.getPasswordHistoriesForUserAsFlow(userId)
}
override suspend fun insertPasswordHistory(
passwordHistoryEntity: PasswordHistoryEntity,
) {
passwordHistoryDao.insertPasswordHistory(passwordHistoryEntity)
}
override suspend fun clearPasswordHistories(userId: String) {
passwordHistoryDao.clearPasswordHistoriesForUser(userId)
}
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
import kotlinx.coroutines.flow.Flow
/**
* Provides methods for inserting, retrieving, and deleting passcode history items
* from the database, interacting with the [PasswordHistoryEntity] entity.
*/
@Dao
interface PasswordHistoryDao {
/**
* Inserts a password history item into the database.
*/
@Insert
suspend fun insertPasswordHistory(passwordHistory: PasswordHistoryEntity)
/**
* Retrieves all password history items for a specific user from the database as a Flow.
*/
@Query("SELECT * FROM password_history WHERE userId = :userId")
fun getPasswordHistoriesForUserAsFlow(userId: String): Flow<List<PasswordHistoryEntity>>
/**
* Clears all password history items from the database for a specific user.
*/
@Query("DELETE FROM password_history WHERE userId = :userId")
suspend fun clearPasswordHistoriesForUser(userId: String)
}

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.PasswordHistoryDao
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
/**
* Room database for storing passcode history.
*/
@Database(entities = [PasswordHistoryEntity::class], version = 1)
abstract class PasswordHistoryDatabase : RoomDatabase() {
/**
* Provides the DAO for accessing passcode history data.
*/
abstract fun passwordHistoryDao(): PasswordHistoryDao
}

View file

@ -1,8 +1,14 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk.di
import android.app.Application
import android.content.SharedPreferences
import androidx.room.Room
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.PasswordHistoryDao
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.database.PasswordHistoryDatabase
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSourceImpl
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSourceImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -27,4 +33,30 @@ object GeneratorDiskModule {
sharedPreferences = sharedPreferences,
json = json,
)
@Provides
@Singleton
fun providePasswordHistoryDiskSource(
passwordHistoryDao: PasswordHistoryDao,
): PasswordHistoryDiskSource = PasswordHistoryDiskSourceImpl(
passwordHistoryDao = passwordHistoryDao,
)
@Provides
@Singleton
fun providePasswordHistoryDatabase(app: Application): PasswordHistoryDatabase {
return Room
.databaseBuilder(
context = app,
klass = PasswordHistoryDatabase::class.java,
name = "passcode_history_database",
)
.build()
}
@Provides
@Singleton
fun providePasswordHistoryDao(database: PasswordHistoryDatabase): PasswordHistoryDao {
return database.passwordHistoryDao()
}
}

View file

@ -0,0 +1,49 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.bitwarden.core.PasswordHistory
import java.time.Instant
/**
* Entity representing a generated history item in the database.
*/
@Entity(tableName = "password_history")
data class PasswordHistoryEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
val id: Int = 0,
@ColumnInfo(name = "userId")
val userId: String,
@ColumnInfo(name = "encrypted_password")
val encryptedPassword: String,
@ColumnInfo(name = "generated_date_time_ms")
val generatedDateTimeMs: Long,
)
/**
* Converts a PasswordHistory object to a GeneratedHistoryItem.
* This function is used to transform data from the SDK model to the database entity model.
*/
fun PasswordHistory.toPasswordHistoryEntity(userId: String): PasswordHistoryEntity {
return PasswordHistoryEntity(
userId = userId,
encryptedPassword = this.password,
generatedDateTimeMs = this.lastUsedDate.toEpochMilli(),
)
}
/**
* Converts a GeneratedHistoryItem object to a PasswordHistory.
* This function is used to transform data from the database entity model to the SDK model.
*/
fun PasswordHistoryEntity.toPasswordHistory(): PasswordHistory {
return PasswordHistory(
password = this.encryptedPassword,
lastUsedDate = Instant.ofEpochMilli(this.generatedDateTimeMs),
)
}

View file

@ -2,15 +2,23 @@ package com.x8bit.bitwarden.data.tools.generator.repository
import com.bitwarden.core.PassphraseGeneratorRequest
import com.bitwarden.core.PasswordGeneratorRequest
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions
import kotlinx.coroutines.flow.StateFlow
/**
* Responsible for managing generator data.
*/
interface GeneratorRepository {
/**
* Retrieve all stored password history items for the current user.
*/
val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>>
/**
* Attempt to generate a password.
*/
@ -34,4 +42,14 @@ interface GeneratorRepository {
* Save the [PasscodeGenerationOptions] for the current user.
*/
fun savePasscodeGenerationOptions(options: PasscodeGenerationOptions)
/**
* Store a password history item for the current user.
*/
suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView)
/**
* Clear all stored password history for the current user.
*/
suspend fun clearPasswordHistory()
}

View file

@ -2,24 +2,103 @@ package com.x8bit.bitwarden.data.tools.generator.repository
import com.bitwarden.core.PassphraseGeneratorRequest
import com.bitwarden.core.PasswordGeneratorRequest
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.toPasswordHistory
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.toPasswordHistoryEntity
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Singleton
/**
* Default implementation of [GeneratorRepository].
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class GeneratorRepositoryImpl constructor(
private val generatorSdkSource: GeneratorSdkSource,
private val generatorDiskSource: GeneratorDiskSource,
private val authDiskSource: AuthDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val passwordHistoryDiskSource: PasswordHistoryDiskSource,
) : GeneratorRepository {
private val scope = CoroutineScope(Dispatchers.IO)
private val mutablePasswordHistoryStateFlow =
MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading)
override val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>>
get() = mutablePasswordHistoryStateFlow.asStateFlow()
private var passwordHistoryJob: Job? = null
init {
mutablePasswordHistoryStateFlow
.subscriptionCount
.flatMapLatest { subscriberCount ->
if (subscriberCount > 0) {
authDiskSource
.userStateFlow
.map { it?.activeUserId }
.distinctUntilChanged()
} else {
flow { awaitCancellation() }
}
}
.onEach { activeUserId ->
observePasswordHistoryForUser(activeUserId)
}
.launchIn(scope)
}
private fun observePasswordHistoryForUser(userId: String?) {
passwordHistoryJob?.cancel()
userId ?: return
mutablePasswordHistoryStateFlow.value = LocalDataState.Loading
passwordHistoryJob = passwordHistoryDiskSource
.getPasswordHistoriesForUser(userId)
.map { encryptedPasswordHistoryList ->
val passwordHistories =
encryptedPasswordHistoryList.map { it.toPasswordHistory() }
vaultSdkSource
.decryptPasswordHistoryList(passwordHistories)
}
.onEach { encryptedPasswordHistoryListResult ->
encryptedPasswordHistoryListResult
.fold(
onSuccess = {
mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(it)
},
onFailure = {
mutablePasswordHistoryStateFlow.value = LocalDataState.Error(it)
},
)
}
.launchIn(scope)
}
override suspend fun generatePassword(
passwordGeneratorRequest: PasswordGeneratorRequest,
): GeneratedPasswordResult =
@ -49,4 +128,19 @@ class GeneratorRepositoryImpl constructor(
val userId = authDiskSource.userState?.activeUserId
userId?.let { generatorDiskSource.storePasscodeGenerationOptions(it, options) }
}
override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) {
val userId = authDiskSource.userState?.activeUserId ?: return
val encryptedPasswordHistory = vaultSdkSource
.encryptPasswordHistory(passwordHistoryView)
.getOrNull() ?: return
passwordHistoryDiskSource.insertPasswordHistory(
encryptedPasswordHistory.toPasswordHistoryEntity(userId),
)
}
override suspend fun clearPasswordHistory() {
val userId = authDiskSource.userState?.activeUserId ?: return
passwordHistoryDiskSource.clearPasswordHistories(userId)
}
}

View file

@ -2,9 +2,11 @@ package com.x8bit.bitwarden.data.tools.generator.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -24,9 +26,13 @@ object GeneratorRepositoryModule {
generatorSdkSource: GeneratorSdkSource,
generatorDiskSource: GeneratorDiskSource,
authDiskSource: AuthDiskSource,
vaultSdkSource: VaultSdkSource,
passwordHistoryDiskSource: PasswordHistoryDiskSource,
): GeneratorRepository = GeneratorRepositoryImpl(
generatorSdkSource = generatorSdkSource,
generatorDiskSource = generatorDiskSource,
authDiskSource = authDiskSource,
vaultSdkSource = vaultSdkSource,
passwordHistoryDiskSource = passwordHistoryDiskSource,
)
}

View file

@ -9,6 +9,8 @@ import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.PasswordHistory
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.Send
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
@ -86,4 +88,18 @@ interface VaultSdkSource {
* Decrypts a list of [Folder]s returning a list of [FolderView] wrapped in a [Result].
*/
suspend fun decryptFolderList(folderList: List<Folder>): Result<List<FolderView>>
/**
* Encrypts a given password history item.
*/
suspend fun encryptPasswordHistory(
passwordHistory: PasswordHistoryView,
): Result<PasswordHistory>
/**
* Decrypts a list of password history items.
*/
suspend fun decryptPasswordHistoryList(
passwordHistoryList: List<PasswordHistory>,
): Result<List<PasswordHistoryView>>
}

View file

@ -9,10 +9,13 @@ import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.PasswordHistory
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.Send
import com.bitwarden.core.SendView
import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientVault
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
@ -24,6 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
class VaultSdkSourceImpl(
private val clientVault: ClientVault,
private val clientCrypto: ClientCrypto,
private val clientPasswordHistory: ClientPasswordHistory,
) : VaultSdkSource {
override suspend fun initializeCrypto(
request: InitUserCryptoRequest,
@ -88,4 +92,16 @@ class VaultSdkSourceImpl(
override suspend fun decryptFolderList(folderList: List<Folder>): Result<List<FolderView>> =
runCatching { clientVault.folders().decryptList(folderList) }
override suspend fun encryptPasswordHistory(
passwordHistory: PasswordHistoryView,
): Result<PasswordHistory> = runCatching {
clientPasswordHistory.encrypt(passwordHistory)
}
override suspend fun decryptPasswordHistoryList(
passwordHistoryList: List<PasswordHistory>,
): Result<List<PasswordHistoryView>> = runCatching {
clientPasswordHistory.decryptList(passwordHistoryList)
}
}

View file

@ -24,5 +24,6 @@ object VaultSdkModule {
VaultSdkSourceImpl(
clientVault = client.vault(),
clientCrypto = client.crypto(),
clientPasswordHistory = client.vault().passwordHistory(),
)
}

View file

@ -0,0 +1,64 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.FakePasswordHistoryDao
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
class PasswordHistoryDiskSourceTest {
private val fakePasswordHistoryDao = FakePasswordHistoryDao()
private val diskSource = PasswordHistoryDiskSourceImpl(fakePasswordHistoryDao)
private val testUserId = "testUserId"
@Test
fun `insertPassword calls dao insertPasswordHistory`() = runTest {
val passwordHistoryEntity = PasswordHistoryEntity(
id = 0,
userId = testUserId,
encryptedPassword = "encrypted",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
)
diskSource.insertPasswordHistory(passwordHistoryEntity)
assertTrue(fakePasswordHistoryDao.storedPasswordHistories.contains(passwordHistoryEntity))
}
@Test
fun `getPasswordHistoriesForUser returns flow from dao`() = runTest {
val passwordHistoryEntity = PasswordHistoryEntity(
id = 0,
userId = testUserId,
encryptedPassword = "encrypted",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
)
fakePasswordHistoryDao.insertPasswordHistory(passwordHistoryEntity)
val result = diskSource
.getPasswordHistoriesForUser(testUserId)
.first()
assertEquals(listOf(passwordHistoryEntity), result)
}
@Test
fun `clearPasswordHistoriesForUser calls dao clearPasswordHistoriesForUser`() = runTest {
fakePasswordHistoryDao.storedPasswordHistories.add(
PasswordHistoryEntity(
id = 1,
userId = testUserId,
encryptedPassword = "encrypted",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
),
)
diskSource.clearPasswordHistories(testUserId)
assertTrue(fakePasswordHistoryDao.storedPasswordHistories.none { it.userId == testUserId })
}
}

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class FakePasswordHistoryDao : PasswordHistoryDao {
val storedPasswordHistories = mutableListOf<PasswordHistoryEntity>()
private val passwordHistoriesFlow = MutableSharedFlow<List<PasswordHistoryEntity>>(
replay = 1,
extraBufferCapacity = Int.MAX_VALUE,
)
init {
passwordHistoriesFlow.tryEmit(emptyList())
}
override suspend fun insertPasswordHistory(passwordHistory: PasswordHistoryEntity) {
storedPasswordHistories.add(passwordHistory)
passwordHistoriesFlow.tryEmit(storedPasswordHistories.toList())
}
override fun getPasswordHistoriesForUserAsFlow(
userId: String,
): Flow<List<PasswordHistoryEntity>> {
return passwordHistoriesFlow
.map { histories -> histories.filter { it.userId == userId } }
}
override suspend fun clearPasswordHistoriesForUser(userId: String) {
storedPasswordHistories.removeAll { it.userId == userId }
passwordHistoriesFlow.tryEmit(storedPasswordHistories.toList())
}
}

View file

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity
import com.bitwarden.core.PasswordHistory
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import java.time.Instant
class PasswordHistoryEntityTest {
@Test
fun `toPasswordHistoryEntity should return the correct value`() {
val passwordHistory = PasswordHistory(
password = "testPassword",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
)
val expectedEntity = PasswordHistoryEntity(
id = 0,
userId = "testId",
encryptedPassword = "testPassword",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
)
val entity = passwordHistory.toPasswordHistoryEntity("testId")
assertEquals(expectedEntity, entity)
}
@Test
fun `toPasswordHistory should return the correct value`() {
val entity = PasswordHistoryEntity(
id = 1,
userId = "testId",
encryptedPassword = "testPassword",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
)
val passwordHistory = entity.toPasswordHistory()
val expectedPasswordHistory = PasswordHistory(
password = "testPassword",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
)
assertEquals(expectedPasswordHistory, passwordHistory)
}
}

View file

@ -1,7 +1,10 @@
package com.x8bit.bitwarden.data.tools.generator.repository
import app.cash.turbine.test
import com.bitwarden.core.PassphraseGeneratorRequest
import com.bitwarden.core.PasswordGeneratorRequest
import com.bitwarden.core.PasswordHistory
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
@ -11,34 +14,45 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.toPasswordHistoryEntity
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions
import io.mockk.Runs
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
class GeneratorRepositoryTest {
private val generatorSdkSource: GeneratorSdkSource = mockk()
private val generatorDiskSource: GeneratorDiskSource = mockk()
private val authDiskSource: AuthDiskSource = mockk()
private val passwordHistoryDiskSource: PasswordHistoryDiskSource = mockk()
private val vaultSdkSource: VaultSdkSource = mockk()
private val repository = GeneratorRepositoryImpl(
generatorSdkSource = generatorSdkSource,
generatorDiskSource = generatorDiskSource,
authDiskSource = authDiskSource,
passwordHistoryDiskSource = passwordHistoryDiskSource,
vaultSdkSource = vaultSdkSource,
)
@BeforeEach
@ -213,13 +227,105 @@ class GeneratorRepositoryTest {
coEvery {
generatorDiskSource.storePasscodeGenerationOptions(userId, optionsToSave)
} just Runs
} just runs
repository.savePasscodeGenerationOptions(optionsToSave)
coVerify { generatorDiskSource.storePasscodeGenerationOptions(userId, optionsToSave) }
}
@Test
fun `storePasswordHistory should call encrypt and insert functions`() = runTest {
val testUserId = "testUserId"
val passwordHistoryView = PasswordHistoryView(
password = "decryptedPassword",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
)
val encryptedPasswordHistory = PasswordHistory(
password = "encryptedPassword",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
)
val expectedPasswordHistoryEntity = encryptedPasswordHistory
.toPasswordHistoryEntity(testUserId)
coEvery { authDiskSource.userState?.activeUserId } returns testUserId
coEvery { vaultSdkSource.encryptPasswordHistory(passwordHistoryView) } returns
Result.success(encryptedPasswordHistory)
coEvery {
passwordHistoryDiskSource.insertPasswordHistory(expectedPasswordHistoryEntity)
} just runs
repository.storePasswordHistory(passwordHistoryView)
coVerify { vaultSdkSource.encryptPasswordHistory(passwordHistoryView) }
coVerify { passwordHistoryDiskSource.insertPasswordHistory(expectedPasswordHistoryEntity) }
}
@Test
fun `passwordHistoryStateFlow should emit correct states based on password history updates`() =
runTest {
val encryptedPasswordHistoryEntities = listOf(
PasswordHistoryEntity(
userId = USER_STATE.activeUserId,
encryptedPassword = "encryptedPassword1",
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
),
PasswordHistoryEntity(
userId = USER_STATE.activeUserId,
encryptedPassword = "encryptedPassword2",
generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(),
),
)
val decryptedPasswordHistoryList = listOf(
PasswordHistoryView(
password = "password1",
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
),
PasswordHistoryView(
password = "password2",
lastUsedDate = Instant.parse("2021-01-02T00:00:00Z"),
),
)
coEvery { authDiskSource.userStateFlow } returns flowOf(USER_STATE)
coEvery {
passwordHistoryDiskSource.getPasswordHistoriesForUser(USER_STATE.activeUserId)
} returns flowOf(encryptedPasswordHistoryEntities)
coEvery {
vaultSdkSource.decryptPasswordHistoryList(any())
} returns Result.success(decryptedPasswordHistoryList)
val historyFlow = repository.passwordHistoryStateFlow
historyFlow.test {
assertEquals(LocalDataState.Loading, awaitItem())
assertEquals(LocalDataState.Loaded(decryptedPasswordHistoryList), awaitItem())
cancelAndIgnoreRemainingEvents()
}
coVerify {
passwordHistoryDiskSource.getPasswordHistoriesForUser(USER_STATE.activeUserId)
}
coVerify { vaultSdkSource.decryptPasswordHistoryList(any()) }
}
@Test
fun `clearPasswordHistory should call clearAllPasswords function`() = runTest {
val testUserId = "testUserId"
coEvery { authDiskSource.userState?.activeUserId } returns testUserId
coEvery { passwordHistoryDiskSource.clearPasswordHistories(testUserId) } just runs
repository.clearPasswordHistory()
coVerify { passwordHistoryDiskSource.clearPasswordHistories(testUserId) }
}
@Test
fun `savePasscodeGenerationOptions should not store options when there is no active user`() =
runTest {

View file

@ -2,10 +2,14 @@ package com.x8bit.bitwarden.data.tools.generator.repository.util
import com.bitwarden.core.PassphraseGeneratorRequest
import com.bitwarden.core.PasswordGeneratorRequest
import com.bitwarden.core.PasswordHistoryView
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* A fake implementation of [GeneratorRepository] for testing purposes.
@ -21,6 +25,12 @@ class FakeGeneratorRepository : GeneratorRepository {
)
private var passcodeGenerationOptions: PasscodeGenerationOptions? = null
private val mutablePasswordHistoryStateFlow =
MutableStateFlow<LocalDataState<List<PasswordHistoryView>>>(LocalDataState.Loading)
override val passwordHistoryStateFlow: StateFlow<LocalDataState<List<PasswordHistoryView>>>
get() = mutablePasswordHistoryStateFlow
override suspend fun generatePassword(
passwordGeneratorRequest: PasswordGeneratorRequest,
): GeneratedPasswordResult {
@ -41,6 +51,16 @@ class FakeGeneratorRepository : GeneratorRepository {
passcodeGenerationOptions = options
}
override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) {
val currentList = mutablePasswordHistoryStateFlow.value.data.orEmpty()
val updatedList = currentList + passwordHistoryView
mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(updatedList)
}
override suspend fun clearPasswordHistory() {
mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(emptyList())
}
/**
* Sets the mock result for the generatePassword function.
*/

View file

@ -9,10 +9,13 @@ import com.bitwarden.core.Folder
import com.bitwarden.core.FolderView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.PasswordHistory
import com.bitwarden.core.PasswordHistoryView
import com.bitwarden.core.Send
import com.bitwarden.core.SendView
import com.bitwarden.sdk.BitwardenException
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientVault
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
@ -27,9 +30,11 @@ import org.junit.jupiter.api.Test
class VaultSdkSourceTest {
private val clientVault = mockk<ClientVault>()
private val clientCrypto = mockk<ClientCrypto>()
private val clientPasswordHistory = mockk<ClientPasswordHistory>()
private val vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
clientVault = clientVault,
clientCrypto = clientCrypto,
clientPasswordHistory = clientPasswordHistory,
)
@Test
@ -404,4 +409,50 @@ class VaultSdkSourceTest {
)
}
}
@Test
fun `encryptPasswordHistory should call SDK and return a Result with correct data`() =
runBlocking {
val mockPasswordHistoryView = mockk<PasswordHistoryView>()
val expectedResult = mockk<PasswordHistory>()
coEvery {
clientPasswordHistory.encrypt(
passwordHistory = mockPasswordHistoryView,
)
} returns expectedResult
val result = vaultSdkSource.encryptPasswordHistory(
passwordHistory = mockPasswordHistoryView,
)
assertEquals(expectedResult.asSuccess(), result)
coVerify {
clientPasswordHistory.encrypt(
passwordHistory = mockPasswordHistoryView,
)
}
}
@Test
fun `decryptPasswordHistoryList should call SDK and return a Result with correct data`() =
runBlocking {
val mockPasswordHistoryList = mockk<List<PasswordHistory>>()
val expectedResult = mockk<List<PasswordHistoryView>>()
coEvery {
clientPasswordHistory.decryptList(
list = mockPasswordHistoryList,
)
} returns expectedResult
val result = vaultSdkSource.decryptPasswordHistoryList(
passwordHistoryList = mockPasswordHistoryList,
)
assertEquals(expectedResult.asSuccess(), result)
coVerify {
clientPasswordHistory.decryptList(
list = mockPasswordHistoryList,
)
}
}
}