BIT-598: Create initial vault database (#399)

This commit is contained in:
David Perez 2023-12-15 11:21:05 -06:00 committed by Álison Fernandes
parent 7fa42e5fb8
commit 191602a867
10 changed files with 462 additions and 0 deletions

View file

@ -162,6 +162,7 @@ koverReport {
"com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt",
// Databases
"*.database.*Database",
"*.dao.*Dao",
)
packages(
// Dependency injection

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.vault.datasource.disk
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.coroutines.flow.Flow
/**
* Primary access point for disk information related to vault data.
*/
interface VaultDiskSource {
/**
* Retrieves all ciphers from the data source for a given [userId].
*/
fun getCiphers(userId: String): Flow<List<SyncResponseJson.Cipher>>
/**
* Replaces all [vault] data for a given [userId] with the new `vault`.
*/
suspend fun replaceVaultData(userId: String, vault: SyncResponseJson)
/**
* Deletes all stored vault data from the data source for a given [userId].
*/
suspend fun deleteVaultData(userId: String)
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.vault.datasource.disk
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* Default implementation of [VaultDiskSource].
*/
class VaultDiskSourceImpl(
private val ciphersDao: CiphersDao,
private val json: Json,
) : VaultDiskSource {
override fun getCiphers(
userId: String,
): Flow<List<SyncResponseJson.Cipher>> =
ciphersDao
.getAllCiphers(userId = userId)
.map { entities ->
entities.map { entity ->
json.decodeFromString<SyncResponseJson.Cipher>(entity.cipherJson)
}
}
override suspend fun replaceVaultData(userId: String, vault: SyncResponseJson) {
ciphersDao.replaceAllCiphers(
userId = userId,
ciphers = vault.ciphers.orEmpty().map { cipher ->
CipherEntity(
id = cipher.id,
userId = userId,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
)
},
)
}
override suspend fun deleteVaultData(userId: String) {
ciphersDao.deleteAllCiphers(userId = userId)
}
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import kotlinx.coroutines.flow.Flow
/**
* Provides methods for inserting, retrieving, and deleting ciphers from the database using the
* [CipherEntity].
*/
@Dao
interface CiphersDao {
/**
* Inserts multiple ciphers into the database.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCiphers(ciphers: List<CipherEntity>)
/**
* Retrieves all ciphers from the database for a given [userId].
*/
@Query("SELECT * FROM ciphers WHERE user_id IS :userId")
fun getAllCiphers(
userId: String,
): Flow<List<CipherEntity>>
/**
* Deletes all the stored ciphers associated with the given [userId].
*/
@Query("DELETE FROM ciphers WHERE user_id = :userId")
suspend fun deleteAllCiphers(userId: String)
/**
* Deletes all the stored ciphers associated with the given [userId] and then add all new
* [ciphers] to the database.
*/
@Transaction
suspend fun replaceAllCiphers(userId: String, ciphers: List<CipherEntity>) {
deleteAllCiphers(userId)
insertCiphers(ciphers)
}
}

View file

@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
/**
* Room database for storing any persisted data from the vault sync.
*/
@Database(
entities = [
CipherEntity::class,
],
version = 1,
)
abstract class VaultDatabase : RoomDatabase() {
/**
* Provides the DAO for accessing cipher data.
*/
abstract fun cipherDao(): CiphersDao
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.di
import android.app.Application
import androidx.room.Room
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import javax.inject.Singleton
/**
* Provides database dependencies in the vault package.
*/
@Module
@InstallIn(SingletonComponent::class)
class VaultDiskModule {
@Provides
@Singleton
fun provideVaultDatabase(app: Application): VaultDatabase =
Room
.databaseBuilder(
context = app,
klass = VaultDatabase::class.java,
name = "vault_database",
)
.build()
@Provides
@Singleton
fun provideCipherDao(database: VaultDatabase): CiphersDao = database.cipherDao()
@Provides
@Singleton
fun provideVaultDiskSource(
ciphersDao: CiphersDao,
json: Json,
): VaultDiskSource = VaultDiskSourceImpl(
ciphersDao = ciphersDao,
json = json,
)
}

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Entity representing a cipher in the database.
*/
@Entity(tableName = "ciphers")
data class CipherEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "user_id", index = true)
val userId: String,
@ColumnInfo(name = "cipher_type")
val cipherType: String,
@ColumnInfo(name = "cipher_json")
val cipherJson: String,
)

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.util
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
/**
* Helper method for comparing JSON string and ignoring the formatting.
*/
fun assertJsonEquals(
expected: String,
actual: String,
json: Json = PlatformNetworkModule.providesJson(),
) {
assertEquals(
json.parseToJsonElement(expected),
json.parseToJsonElement(actual),
)
}

View file

@ -0,0 +1,187 @@
package com.x8bit.bitwarden.data.vault.datasource.disk
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.util.assertJsonEquals
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeCiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
import io.mockk.every
import io.mockk.mockk
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.BeforeEach
import org.junit.jupiter.api.Test
class VaultDiskSourceTest {
private val json = PlatformNetworkModule.providesJson()
private lateinit var ciphersDao: FakeCiphersDao
private lateinit var vaultDiskSource: VaultDiskSource
@BeforeEach
fun setup() {
ciphersDao = FakeCiphersDao()
vaultDiskSource = VaultDiskSourceImpl(
ciphersDao = ciphersDao,
json = json,
)
}
@Test
fun `getCiphers should emit all dao updates`() = runTest {
val cipherEntities = listOf(CIPHER_ENTITY)
val ciphers = listOf(CIPHER_1)
vaultDiskSource
.getCiphers(USER_ID)
.test {
assertEquals(emptyList<SyncResponseJson.Cipher>(), awaitItem())
ciphersDao.insertCiphers(cipherEntities)
assertEquals(ciphers, awaitItem())
}
}
@Test
fun `replaceVaultData should clear the dao and insert the encoded ciphers`() = runTest {
assertEquals(ciphersDao.storedCiphers, emptyList<CipherEntity>())
vaultDiskSource.replaceVaultData(USER_ID, VAULT_DATA)
assertEquals(1, ciphersDao.storedCiphers.size)
val storedEntity = ciphersDao.storedCiphers.first()
// We cannot compare the JSON strings directly because of formatting differences
// So we split that off into its own assertion.
assertEquals(CIPHER_ENTITY.copy(cipherJson = ""), storedEntity.copy(cipherJson = ""))
assertJsonEquals(CIPHER_ENTITY.cipherJson, storedEntity.cipherJson)
}
@Test
fun `deleteVaultData should remove all ciphers matching the user ID`() = runTest {
assertFalse(ciphersDao.deleteCiphersCalled)
vaultDiskSource.deleteVaultData(USER_ID)
assertTrue(ciphersDao.deleteCiphersCalled)
}
}
private const val USER_ID: String = "test_user_id"
private val CIPHER_1: SyncResponseJson.Cipher = createMockCipher(1)
private val VAULT_DATA: SyncResponseJson = SyncResponseJson(
folders = null,
collections = null,
profile = mockk<SyncResponseJson.Profile> {
every { id } returns USER_ID
},
ciphers = listOf(CIPHER_1),
policies = null,
domains = SyncResponseJson.Domains(
globalEquivalentDomains = null,
equivalentDomains = null,
),
sends = null,
)
private const val CIPHER_JSON = """
{
"notes": "mockNotes-1",
"attachments": [
{
"fileName": "mockFileName-1",
"size": 1,
"sizeName": "mockSizeName-1",
"id": "mockId-1",
"url": "mockUrl-1",
"key": "mockKey-1"
}
],
"organizationUseTotp": false,
"reprompt": 0,
"edit": false,
"passwordHistory": [
{
"password": "mockPassword-1",
"lastUsedDate": "2023-10-27T12:00:00.000Z"
}
],
"revisionDate": "2023-10-27T12:00:00.000Z",
"type": 1,
"login": {
"uris": [
{
"match": 1,
"uri": "mockUri-1"
}
],
"totp": "mockTotp-1",
"password": "mockPassword-1",
"passwordRevisionDate": "2023-10-27T12:00:00.000Z",
"autofillOnPageLoad": false,
"uri": "mockUri-1",
"username": "mockUsername-1"
},
"creationDate": "2023-10-27T12:00:00.000Z",
"secureNote": {
"type": 0
},
"folderId": "mockFolderId-1",
"organizationId": "mockOrganizationId-1",
"deletedDate": "2023-10-27T12:00:00.000Z",
"identity": {
"passportNumber": "mockPassportNumber-1",
"lastName": "mockLastName-1",
"address3": "mockAddress3-1",
"address2": "mockAddress2-1",
"city": "mockCity-1",
"country": "mockCountry-1",
"address1": "mockAddress1-1",
"postalCode": "mockPostalCode-1",
"title": "mockTitle-1",
"ssn": "mockSsn-1",
"firstName": "mockFirstName-1",
"phone": "mockPhone-1",
"middleName": "mockMiddleName-1",
"company": "mockCompany-1",
"licenseNumber": "mockLicenseNumber-1",
"state": "mockState-1",
"email": "mockEmail-1",
"username": "mockUsername-1"
},
"collectionIds": [
"mockCollectionId-1"
],
"name": "mockName-1",
"id": "mockId-1",
"fields": [
{
"linkedId": 100,
"name": "mockName-1",
"type": 1,
"value": "mockValue-1"
}
],
"viewPassword": false,
"favorite": false,
"card": {
"number": "mockNumber-1",
"expMonth": "mockExpMonth-1",
"code": "mockCode-1",
"expYear": "mockExpirationYear-1",
"cardholderName": "mockCardholderName-1",
"brand": "mockBrand-1"
},
"key": "mockKey-1"
}
"""
private val CIPHER_ENTITY = CipherEntity(
id = "mockId-1",
userId = USER_ID,
cipherType = "1",
cipherJson = CIPHER_JSON,
)

View file

@ -0,0 +1,42 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.dao
import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class FakeCiphersDao : CiphersDao {
val storedCiphers = mutableListOf<CipherEntity>()
var deleteCiphersCalled: Boolean = false
private val ciphersFlow = MutableSharedFlow<List<CipherEntity>>(
replay = 1,
extraBufferCapacity = Int.MAX_VALUE,
)
init {
ciphersFlow.tryEmit(emptyList())
}
override suspend fun deleteAllCiphers(userId: String) {
deleteCiphersCalled = true
storedCiphers.removeAll { it.userId == userId }
ciphersFlow.tryEmit(storedCiphers.toList())
}
override fun getAllCiphers(userId: String): Flow<List<CipherEntity>> =
ciphersFlow.map { ciphers -> ciphers.filter { it.userId == userId } }
override suspend fun insertCiphers(ciphers: List<CipherEntity>) {
storedCiphers.addAll(ciphers)
ciphersFlow.tryEmit(ciphers.toList())
}
override suspend fun replaceAllCiphers(userId: String, ciphers: List<CipherEntity>) {
storedCiphers.removeAll { it.userId == userId }
storedCiphers.addAll(ciphers)
ciphersFlow.tryEmit(ciphers.toList())
}
}