[PM-9401] Server feature flags manager (#3656)

This commit is contained in:
André Bispo 2024-08-06 16:00:22 +01:00 committed by GitHub
parent 02167024b1
commit 994a577600
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 459 additions and 57 deletions

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonPrimitive
/**
* Represents the response model for configuration data fetched from the server.
@ -31,7 +32,7 @@ data class ConfigResponseJson(
val environment: EnvironmentJson?,
@SerialName("featureStates")
val featureStates: Map<String, Boolean>?,
val featureStates: Map<String, JsonPrimitive>?,
) {
/**
* Represents a server in the configuration response.

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
/**
* Manages the available feature flags for the Bitwarden application.
*/
interface FeatureFlagManager {
/**
* Returns a map of constant feature flags that are only used locally.
*/
val sdkFeatureFlags: Map<String, Boolean>
/**
* Returns a flow emitting the value of flag [key] which is of generic type [T].
* If the value of the flag cannot be retrieved, the default value of [key] will be returned
*/
fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T>
/**
* Get value for feature flag with [key] and returns it as generic type [T].
* If no value is found the the given [key] its default value will be returned.
* Cached flags can be invalidated with [forceRefresh]
*/
suspend fun <T : Any> getFeatureFlag(
key: FlagKey<T>,
forceRefresh: Boolean,
): T
}

View file

@ -0,0 +1,59 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private const val CIPHER_KEY_ENCRYPTION_KEY = "enableCipherKeyEncryption"
/**
* Primary implementation of [FeatureFlagManager].
*/
class FeatureFlagManagerImpl(
private val serverConfigRepository: ServerConfigRepository,
) : FeatureFlagManager {
override val sdkFeatureFlags: Map<String, Boolean>
get() = mapOf(CIPHER_KEY_ENCRYPTION_KEY to true)
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> =
serverConfigRepository
.serverConfigStateFlow
.map { serverConfig ->
serverConfig.getFlagValueOrDefault(key = key)
}
override suspend fun <T : Any> getFeatureFlag(
key: FlagKey<T>,
forceRefresh: Boolean,
): T =
serverConfigRepository
.getServerConfig(forceRefresh = forceRefresh)
.getFlagValueOrDefault(key = key)
}
private fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
val defaultValue = key.defaultValue
return this?.serverData
?.featureStates
?.get(key.keyName)
?.let {
try {
// Suppressed since we are checking the type before doing the cast
@Suppress("UNCHECKED_CAST")
when (defaultValue::class) {
Boolean::class -> it.content.toBoolean() as T
String::class -> it.content as T
Int::class -> it.content.toInt() as T
else -> defaultValue
}
} catch (ex: ClassCastException) {
defaultValue
} catch (ex: NumberFormatException) {
defaultValue
}
}
?: defaultValue
}

View file

@ -1,16 +1,15 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
/**
* Primary implementation of [SdkClientManager].
*/
class SdkClientManagerImpl(
private val featureFlagManager: BitwardenFeatureFlagManager,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend () -> Client = {
Client(settings = null).apply {
platform().loadFlags(featureFlagManager.featureFlags)
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
}
},
) : SdkClientManager {

View file

@ -22,6 +22,8 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
@ -47,8 +49,8 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@ -136,10 +138,19 @@ object PlatformManagerModule {
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun providesFeatureFlagManager(
serverConfigRepository: ServerConfigRepository,
): FeatureFlagManager =
FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
)
@Provides
@Singleton
fun provideSdkClientManager(
featureFlagManager: BitwardenFeatureFlagManager,
featureFlagManager: FeatureFlagManager,
): SdkClientManager = SdkClientManagerImpl(
featureFlagManager = featureFlagManager,
)

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Class to hold feature flag keys.
* @property [keyName] corresponds to the string value of a given key
* @property [defaultValue] corresponds to default value of the flag of type [T]
*/
sealed class FlagKey<out T : Any> {
abstract val keyName: String
abstract val defaultValue: T
/**
* Data object holding the key for Email Verification feature
*/
data object EmailVerification : FlagKey<Boolean>() {
override val keyName: String = "email-verification"
override val defaultValue: Boolean = false
}
/**
* Data object holding the key for an Int flag to be used in tests
*/
data object DummyInt : FlagKey<Int>() {
override val keyName: String = "dummy-int"
override val defaultValue: Int = Int.MIN_VALUE
}
/**
* Data object holding the key for an String flag to be used in tests
*/
data object DummyString : FlagKey<String>() {
override val keyName: String = "dummy-string"
override val defaultValue: String = "defaultValue"
}
}

View file

@ -1,11 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
/**
* Manages the available feature flags for the Bitwarden application.
*/
interface BitwardenFeatureFlagManager {
/**
* Returns a map of feature flags.
*/
val featureFlags: Map<String, Boolean>
}

View file

@ -1,11 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
private const val CIPHER_KEY_ENCRYPTION_KEY = "enableCipherKeyEncryption"
/**
* Primary implementation of [BitwardenFeatureFlagManager].
*/
class BitwardenFeatureFlagManagerImpl : BitwardenFeatureFlagManager {
override val featureFlags: Map<String, Boolean>
get() = mapOf(CIPHER_KEY_ENCRYPTION_KEY to true)
}

View file

@ -4,8 +4,6 @@ import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManagerImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialStoreImpl
@ -34,11 +32,6 @@ object VaultSdkModule {
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun providesBitwardenFeatureFlagManager(): BitwardenFeatureFlagManager =
BitwardenFeatureFlagManagerImpl()
@Provides
@Singleton
fun providesFido2CredentialStore(

View file

@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponse
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson.ServerJson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@ -106,6 +107,9 @@ private val SERVER_CONFIG = ServerConfig(
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
),
featureStates = mapOf("duo-redirect" to true, "flexible-collections-v-1" to false),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
"flexible-collections-v-1" to JsonPrimitive(false),
),
),
)

View file

@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import com.x8bit.bitwarden.data.platform.util.asSuccess
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -61,6 +62,6 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
ssoUrl = "ssoUrl",
),
featureStates = mapOf(
"feature one" to false,
"feature one" to JsonPrimitive(false),
),
)

View file

@ -0,0 +1,244 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson.ServerJson
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.FakeServerConfigRepository
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import java.time.Instant
class FeatureFlagManagerTest {
private val fakeServerConfigRepository = FakeServerConfigRepository()
private var manager = FeatureFlagManagerImpl(
serverConfigRepository = fakeServerConfigRepository,
)
@Test
fun `sdkFeatureFlags should return set feature flags`() {
val expected = mapOf("enableCipherKeyEncryption" to true)
val actual = manager.sdkFeatureFlags
assertEquals(expected, actual)
}
@Test
fun `ServerConfigRepository flow with value should trigger new flags`() = runTest {
fakeServerConfigRepository.serverConfigValue = null
assertNull(
fakeServerConfigRepository.serverConfigValue,
)
// This should trigger a new server config to be fetched
fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG
manager.getFeatureFlagFlow(FlagKey.EmailVerification).test {
assertNotNull(
awaitItem(),
)
}
}
@Test
fun `ServerConfigRepository flow with null should trigger default flag value value`() =
runTest {
fakeServerConfigRepository.serverConfigValue = null
manager.getFeatureFlagFlow(FlagKey.EmailVerification).test {
assertFalse(
awaitItem(),
)
}
}
@Test
fun `getFeatureFlag Boolean should return value if exists`() = runTest {
val flagValue = manager.getFeatureFlag(
key = FlagKey.EmailVerification,
forceRefresh = true,
)
assertTrue(flagValue)
}
@Test
fun `getFeatureFlag Boolean should return default value if doesn't exists`() = runTest {
fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy(
serverData = SERVER_CONFIG
.serverData
.copy(
featureStates = mapOf("flag-example" to JsonPrimitive(123)),
),
)
val flagValue = manager.getFeatureFlag(
key = FlagKey.EmailVerification,
forceRefresh = false,
)
assertFalse(flagValue)
}
@Test
fun `getFeatureFlag Int should return value if exists`() = runTest {
fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy(
serverData = SERVER_CONFIG
.serverData
.copy(
featureStates = mapOf("dummy-int" to JsonPrimitive(123)),
),
)
val flagValue = manager.getFeatureFlag(
key = FlagKey.DummyInt,
forceRefresh = false,
)
assertEquals(
123,
flagValue,
)
}
@Test
fun `getFeatureFlag Int should return default value if doesn't exists`() = runTest {
fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy(
serverData = SERVER_CONFIG
.serverData
.copy(
featureStates = mapOf("flag-example" to JsonPrimitive(123)),
),
)
val flagValue = manager.getFeatureFlag(
key = FlagKey.DummyInt,
forceRefresh = false,
)
assertEquals(
Int.MIN_VALUE,
flagValue,
)
}
@Test
fun `getFeatureFlag String should return value if exists`() = runTest {
fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy(
serverData = SERVER_CONFIG
.serverData
.copy(
featureStates = mapOf("dummy-string" to JsonPrimitive("niceValue")),
),
)
val flagValue = manager.getFeatureFlag(
key = FlagKey.DummyString,
forceRefresh = false,
)
assertEquals(
"niceValue",
flagValue,
)
}
@Test
fun `getFeatureFlag String should return default value if doesn't exists`() =
runTest {
fakeServerConfigRepository.serverConfigValue = SERVER_CONFIG.copy(
serverData = SERVER_CONFIG
.serverData
.copy(
featureStates = mapOf("flag-example" to JsonPrimitive("niceValue")),
),
)
val flagValue = manager.getFeatureFlag(
key = FlagKey.DummyString,
forceRefresh = false,
)
assertEquals(
"defaultValue",
flagValue,
)
}
@Test
fun `getFeatureFlag Boolean should return default value if no flags available`() = runTest {
fakeServerConfigRepository.serverConfigValue = null
val flagValue = manager.getFeatureFlag(
key = FlagKey.EmailVerification,
forceRefresh = false,
)
assertFalse(
flagValue,
)
}
@Test
fun `getFeatureFlag Int should return default value if no flags available`() = runTest {
fakeServerConfigRepository.serverConfigValue = null
val flagValue = manager.getFeatureFlag(
key = FlagKey.DummyInt,
forceRefresh = false,
)
assertEquals(
Int.MIN_VALUE,
flagValue,
)
}
@Test
fun `getFeatureFlag String should return default value if no flags available`() = runTest {
fakeServerConfigRepository.serverConfigValue = null
val flagValue = manager.getFeatureFlag(
key = FlagKey.DummyString,
forceRefresh = false,
)
assertEquals(
"defaultValue",
flagValue,
)
}
}
private val SERVER_CONFIG = ServerConfig(
lastSync = Instant.parse("2023-10-27T12:00:00Z").toEpochMilli(),
serverData = ConfigResponseJson(
type = null,
version = "2024.7.0",
gitHash = "25cf6119-dirty",
server = ServerJson(
name = "example",
url = "https://localhost:8080",
),
environment = EnvironmentJson(
cloudRegion = null,
vaultUrl = "https://localhost:8080",
apiUrl = "http://localhost:4000",
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
),
featureStates = mapOf(
"email-verification" to JsonPrimitive(true),
"flexible-collections-v-1" to JsonPrimitive(false),
),
),
)

View file

@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
@ -162,7 +163,10 @@ private val SERVER_CONFIG = ServerConfig(
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
),
featureStates = mapOf("duo-redirect" to true, "flexible-collections-v-1" to false),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
"flexible-collections-v-1" to JsonPrimitive(false),
),
),
)
@ -182,5 +186,8 @@ private val CONFIG_RESPONSE_JSON = ConfigResponseJson(
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
),
featureStates = mapOf("duo-redirect" to true, "flexible-collections-v-1" to false),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
"flexible-collections-v-1" to JsonPrimitive(false),
),
)

View file

@ -0,0 +1,58 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson.ServerJson
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.json.JsonPrimitive
import java.time.Instant
class FakeServerConfigRepository : ServerConfigRepository {
var serverConfigValue: ServerConfig?
get() = mutableServerConfigFlow.value
set(value) {
mutableServerConfigFlow.value = value
}
private val mutableServerConfigFlow = MutableStateFlow<ServerConfig?>(SERVER_CONFIG)
override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? {
if (forceRefresh) {
return SERVER_CONFIG
}
return serverConfigValue
}
override val serverConfigStateFlow: StateFlow<ServerConfig?>
get() = mutableServerConfigFlow
}
private val SERVER_CONFIG = ServerConfig(
lastSync = Instant.parse("2023-10-27T12:00:00Z").toEpochMilli(),
serverData = ConfigResponseJson(
type = null,
version = "2024.7.0",
gitHash = "25cf6119-dirty",
server = ServerJson(
name = "example",
url = "https://localhost:8080",
),
environment = EnvironmentJson(
cloudRegion = null,
vaultUrl = "https://localhost:8080",
apiUrl = "http://localhost:4000",
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
"flexible-collections-v-1" to JsonPrimitive(false),
"email-verification" to JsonPrimitive(true),
),
),
)

View file

@ -1,18 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
class BitwardenFeatureFlagManagerTest {
private val bitwardenFeatureFlagManager = BitwardenFeatureFlagManagerImpl()
@Test
fun `featureFlags should return set feature flags`() {
val expected = mapOf("enableCipherKeyEncryption" to true)
val actual = bitwardenFeatureFlagManager.featureFlags
assertEquals(expected, actual)
}
}