BIT-654: Adding generator disk source implementation (#245)

This commit is contained in:
joshua-livefront 2023-11-14 15:07:48 -05:00 committed by Álison Fernandes
parent f52f4befba
commit fa9401f2d2
10 changed files with 463 additions and 1 deletions

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.generator.datasource.disk
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
/**
* Primary access point for disk information related to generation.
*/
interface GeneratorDiskSource {
/**
* Retrieves a user's password generation options using a [userId].
*/
fun getPasswordGenerationOptions(userId: String): PasswordGenerationOptions?
/**
* Stores a user's password generation options using a [userId].
*/
fun storePasswordGenerationOptions(userId: String, options: PasswordGenerationOptions?)
}

View file

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.generator.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val PASSWORD_GENERATION_OPTIONS_KEY = "passwordGenerationOptions"
/**
* Primary implementation of [GeneratorDiskSource].
*/
class GeneratorDiskSourceImpl(
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseDiskSource(sharedPreferences),
GeneratorDiskSource {
override fun getPasswordGenerationOptions(userId: String): PasswordGenerationOptions? {
val key = getPasswordGenerationOptionsKey(userId)
return getString(key)?.let { json.decodeFromString(it) }
}
override fun storePasswordGenerationOptions(
userId: String,
options: PasswordGenerationOptions?,
) {
val key = getPasswordGenerationOptionsKey(userId)
putString(
key,
options?.let { json.encodeToString(options) },
)
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences?,
key: String?,
) = Unit
private fun getPasswordGenerationOptionsKey(userId: String): String =
"${BASE_KEY}_${PASSWORD_GENERATION_OPTIONS_KEY}_$userId"
}

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.generator.datasource.disk.di
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.generator.datasource.disk.GeneratorDiskSourceImpl
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 persistence-related dependencies for the generator package.
*/
@Module
@InstallIn(SingletonComponent::class)
object GeneratorDiskModule {
@Provides
@Singleton
fun provideGeneratorDiskSource(
sharedPreferences: SharedPreferences,
json: Json,
): GeneratorDiskSource =
GeneratorDiskSourceImpl(
sharedPreferences = sharedPreferences,
json = json,
)
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.generator.repository
import com.bitwarden.core.PasswordGeneratorRequest
import com.x8bit.bitwarden.data.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
/**
* Responsible for managing generator data.
@ -14,4 +15,14 @@ interface GeneratorRepository {
suspend fun generatePassword(
passwordGeneratorRequest: PasswordGeneratorRequest,
): GeneratedPasswordResult
/**
* Get the [PasswordGenerationOptions] for the current user.
*/
fun getPasswordGenerationOptions(): PasswordGenerationOptions?
/**
* Save the [PasswordGenerationOptions] for the current user.
*/
fun savePasswordGenerationOptions(options: PasswordGenerationOptions)
}

View file

@ -1,8 +1,11 @@
package com.x8bit.bitwarden.data.generator.repository
import com.bitwarden.core.PasswordGeneratorRequest
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
import javax.inject.Singleton
/**
@ -11,6 +14,8 @@ import javax.inject.Singleton
@Singleton
class GeneratorRepositoryImpl constructor(
private val generatorSdkSource: GeneratorSdkSource,
private val generatorDiskSource: GeneratorDiskSource,
private val authDiskSource: AuthDiskSource,
) : GeneratorRepository {
override suspend fun generatePassword(
@ -22,4 +27,14 @@ class GeneratorRepositoryImpl constructor(
onSuccess = { GeneratedPasswordResult.Success(it) },
onFailure = { GeneratedPasswordResult.InvalidRequest },
)
override fun getPasswordGenerationOptions(): PasswordGenerationOptions? {
val userId = authDiskSource.userState?.activeUserId
return userId?.let { generatorDiskSource.getPasswordGenerationOptions(it) }
}
override fun savePasswordGenerationOptions(options: PasswordGenerationOptions) {
val userId = authDiskSource.userState?.activeUserId
userId?.let { generatorDiskSource.storePasswordGenerationOptions(it, options) }
}
}

View file

@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.generator.repository.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.generator.repository.GeneratorRepositoryImpl
@ -20,5 +22,11 @@ object GeneratorRepositoryModule {
@Singleton
fun provideGeneratorRepository(
generatorSdkSource: GeneratorSdkSource,
): GeneratorRepository = GeneratorRepositoryImpl(generatorSdkSource)
generatorDiskSource: GeneratorDiskSource,
authDiskSource: AuthDiskSource,
): GeneratorRepository = GeneratorRepositoryImpl(
generatorSdkSource = generatorSdkSource,
generatorDiskSource = generatorDiskSource,
authDiskSource = authDiskSource,
)
}

View file

@ -0,0 +1,51 @@
package com.x8bit.bitwarden.data.generator.repository.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A data class representing the configuration options for password generation.
*
* @property length The total length of the generated password.
* @property allowAmbiguousChar Indicates whether ambiguous characters are allowed in the password.
* @property hasNumbers Indicates whether the password should contain numbers.
* @property minNumber The minimum number of numeric characters required in the password.
* @property hasUppercase Indicates whether the password should contain uppercase characters.
* @property minUppercase The minimum number of uppercase characters required in the password.
* @property hasLowercase Indicates whether the password should contain lowercase characters.
* @property minLowercase The minimum number of lowercase characters required in the password.
* @property allowSpecial Indicates whether special characters are allowed in the password.
* @property minSpecial The minimum number of special characters required in the password.
*/
@Serializable
data class PasswordGenerationOptions(
@SerialName("length")
val length: Int,
@SerialName("allowAmbiguousChar")
val allowAmbiguousChar: Boolean,
@SerialName("number")
val hasNumbers: Boolean,
@SerialName("minNumber")
val minNumber: Int,
@SerialName("uppercase")
val hasUppercase: Boolean,
@SerialName("minUppercase")
val minUppercase: Int?,
@SerialName("lowercase")
val hasLowercase: Boolean,
@SerialName("minLowercase")
val minLowercase: Int?,
@SerialName("special")
val allowSpecial: Boolean,
@SerialName("minSpecial")
val minSpecial: Int,
)

View file

@ -0,0 +1,84 @@
package com.x8bit.bitwarden.data.generator.datasource.disk
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class GeneratorDiskSourceTest {
private val fakeSharedPreferences = FakeSharedPreferences()
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
private val generatorDiskSource = GeneratorDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
json = json,
)
@Test
fun `getPasswordGenerationOptions should return correct options when available`() {
val userId = "user123"
val options = PasswordGenerationOptions(
length = 14,
allowAmbiguousChar = false,
hasNumbers = true,
minNumber = 0,
hasUppercase = true,
minUppercase = null,
hasLowercase = false,
minLowercase = null,
allowSpecial = false,
minSpecial = 1,
)
val key = "bwPreferencesStorage_passwordGenerationOptions_$userId"
fakeSharedPreferences.edit().putString(key, json.encodeToString(options)).apply()
val result = generatorDiskSource.getPasswordGenerationOptions(userId)
assertEquals(options, result)
}
@Test
fun `getPasswordGenerationOptions should return null when options are not available`() {
val userId = "user123"
val result = generatorDiskSource.getPasswordGenerationOptions(userId)
assertNull(result)
}
@Test
fun `storePasswordGenerationOptions should correctly store options`() {
val userId = "user123"
val options = PasswordGenerationOptions(
length = 14,
allowAmbiguousChar = false,
hasNumbers = true,
minNumber = 0,
hasUppercase = true,
minUppercase = null,
hasLowercase = false,
minLowercase = null,
allowSpecial = false,
minSpecial = 1,
)
val key = "bwPreferencesStorage_passwordGenerationOptions_$userId"
generatorDiskSource.storePasswordGenerationOptions(userId, options)
val storedValue = fakeSharedPreferences.getString(key, null)
assertNotNull(storedValue)
assertEquals(json.encodeToString(options), storedValue)
}
}

View file

@ -1,14 +1,28 @@
package com.x8bit.bitwarden.data.generator.repository
import com.bitwarden.core.PasswordGeneratorRequest
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
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
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.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
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
@ -16,9 +30,13 @@ import org.junit.jupiter.api.Test
class GeneratorRepositoryTest {
private val generatorSdkSource: GeneratorSdkSource = mockk()
private val generatorDiskSource: GeneratorDiskSource = mockk()
private val authDiskSource: AuthDiskSource = mockk()
private val repository = GeneratorRepositoryImpl(
generatorSdkSource = generatorSdkSource,
generatorDiskSource = generatorDiskSource,
authDiskSource = authDiskSource,
)
@BeforeEach
@ -73,4 +91,156 @@ class GeneratorRepositoryTest {
assertTrue(result is GeneratedPasswordResult.InvalidRequest)
coVerify { generatorSdkSource.generatePassword(request) }
}
@Test
fun `getPasswordGenerationOptions should return options when available`() = runTest {
val userId = "activeUserId"
val expectedOptions = PasswordGenerationOptions(
length = 14,
allowAmbiguousChar = false,
hasNumbers = true,
minNumber = 0,
hasUppercase = true,
minUppercase = null,
hasLowercase = false,
minLowercase = null,
allowSpecial = false,
minSpecial = 1,
)
coEvery { authDiskSource.userState } returns USER_STATE
coEvery {
generatorDiskSource.getPasswordGenerationOptions(userId)
} returns expectedOptions
val result = repository.getPasswordGenerationOptions()
assertEquals(expectedOptions, result)
coVerify { generatorDiskSource.getPasswordGenerationOptions(userId) }
}
@Test
fun `getPasswordGenerationOptions should return null when there is no active user`() = runTest {
coEvery { authDiskSource.userState } returns null
val result = repository.getPasswordGenerationOptions()
assertNull(result)
coVerify(exactly = 0) { generatorDiskSource.getPasswordGenerationOptions(any()) }
}
@Suppress("MaxLineLength")
@Test
fun `getPasswordGenerationOptions should return null when no data is stored for active user`() = runTest {
val userId = "activeUserId"
coEvery { authDiskSource.userState } returns USER_STATE
coEvery { generatorDiskSource.getPasswordGenerationOptions(userId) } returns null
val result = repository.getPasswordGenerationOptions()
assertNull(result)
coVerify { generatorDiskSource.getPasswordGenerationOptions(userId) }
}
@Test
fun `savePasswordGenerationOptions should store options correctly`() = runTest {
val userId = "activeUserId"
val optionsToSave = PasswordGenerationOptions(
length = 14,
allowAmbiguousChar = false,
hasNumbers = true,
minNumber = 0,
hasUppercase = true,
minUppercase = null,
hasLowercase = false,
minLowercase = null,
allowSpecial = false,
minSpecial = 1,
)
coEvery { authDiskSource.userState } returns USER_STATE
coEvery {
generatorDiskSource.storePasswordGenerationOptions(userId, optionsToSave)
} just Runs
repository.savePasswordGenerationOptions(optionsToSave)
coVerify { generatorDiskSource.storePasswordGenerationOptions(userId, optionsToSave) }
}
@Suppress("MaxLineLength")
@Test
fun `savePasswordGenerationOptions should not store options when there is no active user`() = runTest {
val optionsToSave = PasswordGenerationOptions(
length = 14,
allowAmbiguousChar = false,
hasNumbers = true,
minNumber = 0,
hasUppercase = true,
minUppercase = null,
hasLowercase = false,
minLowercase = null,
allowSpecial = false,
minSpecial = 1,
)
coEvery { authDiskSource.userState } returns null
repository.savePasswordGenerationOptions(optionsToSave)
coVerify(exactly = 0) { generatorDiskSource.storePasswordGenerationOptions(any(), any()) }
}
private val USER_STATE = UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = "stamp",
organizationId = "organizationId",
avatarColorHex = "avatarColorHex",
hasPremium = true,
forcePasswordResetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasManageResetPasswordPermission = true,
),
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
keyConnectorUrl = "keyConnectorUrl",
),
),
),
tokens = AccountJson.Tokens(
accessToken = "accessToken",
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = EnvironmentUrlDataJson(
base = "base",
api = "api",
identity = "identity",
icon = "icon",
notifications = "notifications",
webVault = "webVault",
events = "events",
),
),
),
),
)
}

View file

@ -0,0 +1,31 @@
package com.x8bit.bitwarden.data.generator.repository.util
import com.bitwarden.core.PasswordGeneratorRequest
import com.x8bit.bitwarden.data.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.generator.repository.model.GeneratedPasswordResult
import com.x8bit.bitwarden.data.generator.repository.model.PasswordGenerationOptions
/**
* A fake implementation of [GeneratorRepository] for testing purposes.
* This class provides a simplified way to set up and control responses for repository methods.
*/
class FakeGeneratorRepository : GeneratorRepository {
private var generatePasswordResult: GeneratedPasswordResult = GeneratedPasswordResult.Success(
generatedString = "pa11w0rd",
)
private var passwordGenerationOptions: PasswordGenerationOptions? = null
override suspend fun generatePassword(
passwordGeneratorRequest: PasswordGeneratorRequest,
): GeneratedPasswordResult {
return generatePasswordResult
}
override fun getPasswordGenerationOptions(): PasswordGenerationOptions? {
return passwordGenerationOptions
}
override fun savePasswordGenerationOptions(options: PasswordGenerationOptions) {
passwordGenerationOptions = options
}
}