[PM-9875] Server configurations (#3645)
Some checks failed
Crowdin Push / Crowdin Push (push) Waiting to run
Scan / Check PR run (push) Failing after 0s
Scan / SAST scan (push) Has been skipped
Scan / Quality scan (push) Has been skipped
Test / Check PR run (push) Failing after 0s
Test / Test (push) Has been skipped

This commit is contained in:
André Bispo 2024-07-30 20:23:33 +01:00 committed by GitHub
parent b26e1a082e
commit 646566edd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 536 additions and 1 deletions

View file

@ -7,6 +7,7 @@ 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 com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@ -32,4 +33,7 @@ class BitwardenApplication : Application() {
@Inject
lateinit var restrictionManager: RestrictionManager
@Inject
lateinit var serverConfigRepository: ServerConfigRepository
}

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import kotlinx.coroutines.flow.Flow
/**
* Primary access point for server configuration-related disk information.
*/
interface ConfigDiskSource {
/**
* The currently persisted [ServerConfig] (or `null` if not set).
*/
var serverConfig: ServerConfig?
/**
* Emits updates that track [ServerConfig]. This will replay the last known value,
* if any.
*/
val serverConfigFlow: Flow<ServerConfig?>
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val SERVER_CONFIGURATIONS = "serverConfigurations"
/**
* Primary implementation of [ConfigDiskSource].
*/
class ConfigDiskSourceImpl(
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseDiskSource(sharedPreferences = sharedPreferences),
ConfigDiskSource {
override var serverConfig: ServerConfig?
get() = getString(key = SERVER_CONFIGURATIONS)?.let { json.decodeFromStringOrNull(it) }
set(value) {
putString(
key = SERVER_CONFIGURATIONS,
value = value?.let { json.encodeToString(it) },
)
mutableServerConfigFlow.tryEmit(value)
}
override val serverConfigFlow: Flow<ServerConfig?>
get() = mutableServerConfigFlow.onSubscription { emit(serverConfig) }
private val mutableServerConfigFlow = bufferedMutableSharedFlow<ServerConfig?>(replay = 1)
}

View file

@ -24,7 +24,7 @@ class EnvironmentDiskSourceImpl(
set(value) {
putString(
key = PRE_AUTH_URLS_KEY,
value = value?.let { json.encodeToString(value) },
value = value?.let { json.encodeToString(it) },
)
mutableEnvironmentUrlDataFlow.tryEmit(value)
}

View file

@ -6,6 +6,8 @@ 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.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSourceImpl
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
@ -51,6 +53,17 @@ object PlatformDiskModule {
json = json,
)
@Provides
@Singleton
fun provideConfigDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): ConfigDiskSource =
ConfigDiskSourceImpl(
sharedPreferences = sharedPreferences,
json = json,
)
@Provides
@Singleton
fun provideEventDatabase(app: Application): PlatformDatabase =

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.model
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import kotlinx.serialization.Serializable
/**
* A higher-level wrapper around [ConfigResponseJson] that provides a timestamp
* to check if a sync is necessary
*
* @property lastSync The [Long] of the last sync.
* @property serverData The raw [ConfigResponseJson] that contains specific data of the
* server configuration
*/
@Serializable
data class ServerConfig(
val lastSync: Long,
val serverData: ConfigResponseJson,
)

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing the server config state.
*/
interface ServerConfigRepository {
/**
* Gets the state [ServerConfig]. If needed or forced by [forceRefresh],
* updates the values using server side data.
*/
suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig?
/**
* Emits updates that track [ServerConfig].
*/
val serverConfigStateFlow: StateFlow<ServerConfig?>
}

View file

@ -0,0 +1,77 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import java.time.Instant
/**
* Primary implementation of [ServerConfigRepositoryImpl].
*/
class ServerConfigRepositoryImpl(
private val configDiskSource: ConfigDiskSource,
private val configService: ConfigService,
private val clock: Clock,
environmentRepository: EnvironmentRepository,
dispatcherManager: DispatcherManager,
) : ServerConfigRepository {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
init {
environmentRepository
.environmentStateFlow
.onEach {
getServerConfig(true)
}
.launchIn(unconfinedScope)
}
override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? {
val localConfig = configDiskSource.serverConfig
val needsRefresh = localConfig == null ||
Instant
.ofEpochMilli(localConfig.lastSync)
.isAfter(
clock.instant().plusSeconds(MINIMUM_CONFIG_SYNC_INTERVAL_SEC),
)
if (needsRefresh || forceRefresh) {
configService
.getConfig()
.onSuccess { configResponse ->
val serverConfig = ServerConfig(
lastSync = clock.instant().toEpochMilli(),
serverData = configResponse,
)
configDiskSource.serverConfig = serverConfig
return serverConfig
}
}
// If we are unable to retrieve a configuration from the server,
// fall back to the local configuration.
return localConfig
}
override val serverConfigStateFlow: StateFlow<ServerConfig?>
get() = configDiskSource
.serverConfigFlow
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = configDiskSource.serverConfig,
)
companion object {
private const val MINIMUM_CONFIG_SYNC_INTERVAL_SEC: Long = 60 * 60
}
}

View file

@ -3,13 +3,17 @@ package com.x8bit.bitwarden.data.platform.repository.di
import android.view.autofill.AutofillManager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -17,6 +21,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@ -26,6 +31,23 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformRepositoryModule {
@Provides
@Singleton
fun provideServerConfigRepository(
configDiskSource: ConfigDiskSource,
configService: ConfigService,
clock: Clock,
environmentRepository: EnvironmentRepository,
dispatcherManager: DispatcherManager,
): ServerConfigRepository =
ServerConfigRepositoryImpl(
configDiskSource = configDiskSource,
configService = configService,
clock = clock,
environmentRepository = environmentRepository,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideEnvironmentRepository(

View file

@ -0,0 +1,111 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import androidx.core.content.edit
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
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 kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.time.Instant
class ConfigDiskSourceTest {
private val json = PlatformNetworkModule.providesJson()
private val fakeSharedPreferences = FakeSharedPreferences()
private val configDiskSource = ConfigDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
json = json,
)
@Test
fun `serverConfig should pull from and update SharedPreferences`() {
val serverConfigKey = "bwPreferencesStorage:serverConfigurations"
// Shared preferences and the repository start with the same value.
assertNull(configDiskSource.serverConfig)
assertNull(fakeSharedPreferences.getString(serverConfigKey, null))
// Updating the repository updates shared preferences
configDiskSource.serverConfig = SERVER_CONFIG
assertEquals(
json.parseToJsonElement(
SERVER_CONFIG_JSON,
),
json.parseToJsonElement(
fakeSharedPreferences.getString(serverConfigKey, null)!!,
),
)
// Update SharedPreferences updates the repository
fakeSharedPreferences.edit { putString(serverConfigKey, null) }
assertNull(configDiskSource.serverConfig)
}
@Test
fun `serverConfigFlow should react to changes in serverConfig`() =
runTest {
configDiskSource.serverConfigFlow.test {
// The initial values of the Flow and the property are in sync
assertNull(configDiskSource.serverConfig)
assertNull(awaitItem())
// Updating the repository updates shared preferences
configDiskSource.serverConfig = SERVER_CONFIG
assertEquals(SERVER_CONFIG, awaitItem())
}
}
}
private const val SERVER_CONFIG_JSON = """
{
"lastSync": 1698408000000,
"serverData": {
"version": "2024.7.0",
"gitHash": "25cf6119-dirty",
"server": {
"name": "example",
"url": "https://localhost:8080"
},
"environment": {
"vault": "https://localhost:8080",
"api": "http://localhost:4000",
"identity": "http://localhost:33656",
"notifications": "http://localhost:61840",
"sso": "http://localhost:51822"
},
"featureStates": {
"duo-redirect": true,
"flexible-collections-v-1": false
}
}
}
"""
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 true, "flexible-collections-v-1" to false),
),
)

View file

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.util
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
class FakeConfigDiskSource : ConfigDiskSource {
private var serverConfigValue: ServerConfig? = null
override var serverConfig: ServerConfig?
get() = serverConfigValue
set(value) {
serverConfigValue = value
mutableServerConfigFlow.tryEmit(value)
}
override val serverConfigFlow: Flow<ServerConfig?>
get() = mutableServerConfigFlow
.onSubscription { emit(serverConfig) }
private val mutableServerConfigFlow =
bufferedMutableSharedFlow<ServerConfig?>(replay = 1)
}

View file

@ -0,0 +1,186 @@
package com.x8bit.bitwarden.data.platform.repository
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeConfigDiskSource
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.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class ServerConfigRepositoryTest {
private val fakeDispatcherManager: DispatcherManager = FakeDispatcherManager()
private val fakeConfigDiskSource = FakeConfigDiskSource()
private val configService: ConfigService = mockk {
coEvery {
getConfig()
} returns CONFIG_RESPONSE_JSON.asSuccess()
}
private val environmentRepository = FakeEnvironmentRepository().apply {
environment = Environment.Us
}
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val repository = ServerConfigRepositoryImpl(
configDiskSource = fakeConfigDiskSource,
configService = configService,
clock = fixedClock,
environmentRepository = environmentRepository,
dispatcherManager = fakeDispatcherManager,
)
@BeforeEach
fun setUp() {
fakeConfigDiskSource.serverConfig = null
}
@Test
fun `environmentRepository stateflow should trigger new server configuration`() = runTest {
assertNull(
fakeConfigDiskSource.serverConfig,
)
// This should trigger a new server config to be fetched
environmentRepository.environment = Environment.Eu
repository.serverConfigStateFlow.test {
assertEquals(
SERVER_CONFIG,
awaitItem(),
)
}
}
@Test
fun `getServerConfig should fetch a new server configuration with force refresh as true`() =
runTest {
coEvery {
configService.getConfig()
} returns CONFIG_RESPONSE_JSON.copy(version = "NEW VERSION").asSuccess()
fakeConfigDiskSource.serverConfig = SERVER_CONFIG.copy(
lastSync = fixedClock.instant().toEpochMilli(),
)
assertEquals(
fakeConfigDiskSource.serverConfig,
SERVER_CONFIG,
)
repository.getServerConfig(forceRefresh = true)
assertNotEquals(
fakeConfigDiskSource.serverConfig,
SERVER_CONFIG,
)
}
@Test
fun `getServerConfig should fetch a new server configuration if there is none in state`() =
runTest {
assertNull(
fakeConfigDiskSource.serverConfig,
)
repository.getServerConfig(forceRefresh = false)
assertEquals(
fakeConfigDiskSource.serverConfig,
SERVER_CONFIG,
)
}
@Test
fun `getServerConfig should return state server config if refresh is not necessary`() =
runTest {
val testConfig = SERVER_CONFIG.copy(
lastSync = fixedClock.instant().plusSeconds(1000L).toEpochMilli(),
serverData = CONFIG_RESPONSE_JSON.copy(
version = "new version!!",
),
)
fakeConfigDiskSource.serverConfig = testConfig
coEvery {
configService.getConfig()
} returns CONFIG_RESPONSE_JSON.asSuccess()
repository.getServerConfig(forceRefresh = false)
assertEquals(
fakeConfigDiskSource.serverConfig,
testConfig,
)
}
@Test
fun `serverConfigStateFlow should react to new server configurations`() = runTest {
repository.getServerConfig(forceRefresh = true)
repository.serverConfigStateFlow.test {
assertEquals(fakeConfigDiskSource.serverConfig, awaitItem())
}
}
}
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 true, "flexible-collections-v-1" to false),
),
)
private val CONFIG_RESPONSE_JSON = 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 true, "flexible-collections-v-1" to false),
)