diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bbd05088ab..131337341d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,7 +166,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.square.okhttp) implementation(libs.square.okhttp.logging) + implementation(platform(libs.square.retrofit.bom)) implementation(libs.square.retrofit) + implementation(libs.square.retrofit.kotlinx.serialization) implementation(libs.zxing.zxing.core) // For now we are restricted to running Compose tests for debug builds only diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSource.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSource.kt new file mode 100644 index 0000000000..04a03173f5 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSource.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import com.bitwarden.authenticator.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 +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceImpl.kt new file mode 100644 index 0000000000..52e46bc3d8 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceImpl.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.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 = "$BASE_KEY: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 + get() = mutableServerConfigFlow.onSubscription { emit(serverConfig) } + + private val mutableServerConfigFlow = bufferedMutableSharedFlow(replay = 1) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt index 4530311b14..1dc6e83c03 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -2,6 +2,8 @@ package com.bitwarden.authenticator.data.platform.datasource.disk.di import android.content.SharedPreferences import com.bitwarden.authenticator.data.platform.datasource.di.UnencryptedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSourceImpl import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSourceImpl import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource @@ -20,6 +22,17 @@ import javax.inject.Singleton */ object PlatformDiskModule { + @Provides + @Singleton + fun provideConfigDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + json: Json, + ): ConfigDiskSource = + ConfigDiskSourceImpl( + sharedPreferences = sharedPreferences, + json = json, + ) + @Provides @Singleton fun provideSettingsDiskSource( diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/ServerConfig.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/ServerConfig.kt new file mode 100644 index 0000000000..7e6e2de1a7 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/model/ServerConfig.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.model + +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import kotlinx.serialization.SerialName +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( + @SerialName("lastSync") + val lastSync: Long, + + @SerialName("serverData") + val serverData: ConfigResponseJson, +) diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/api/ConfigApi.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/api/ConfigApi.kt new file mode 100644 index 0000000000..f3e3d2aed2 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/api/ConfigApi.kt @@ -0,0 +1,13 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.api + +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import retrofit2.http.GET + +/** + * This interface defines the API service for fetching configuration data. + */ +interface ConfigApi { + + @GET("config") + suspend fun getConfig(): Result +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt index 6050243c2b..e4404e9eec 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -5,6 +5,8 @@ import com.bitwarden.authenticator.data.platform.datasource.network.interceptor. import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.Retrofits import com.bitwarden.authenticator.data.platform.datasource.network.retrofit.RetrofitsImpl import com.bitwarden.authenticator.data.platform.datasource.network.serializer.ZonedDateTimeSerializer +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigServiceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -12,6 +14,7 @@ import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual +import retrofit2.create import javax.inject.Singleton @Module @@ -21,6 +24,12 @@ import javax.inject.Singleton * It initializes and configures the networking components. */ object PlatformNetworkModule { + @Provides + @Singleton + fun providesConfigService( + retrofits: Retrofits, + ): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create()) + @Provides @Singleton fun providesHeadersInterceptor(): HeadersInterceptor = HeadersInterceptor() diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/model/ConfigResponseJson.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/model/ConfigResponseJson.kt new file mode 100644 index 0000000000..a5c493ef37 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/model/ConfigResponseJson.kt @@ -0,0 +1,82 @@ +package com.bitwarden.authenticator.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. + * + * @property type The object type, typically "config". + * @property version The version of the configuration data. + * @property gitHash The Git hash associated with the configuration data. + * @property server The server information (nullable). + * @property environment The environment information containing URLs (vault, api, identity, etc.). + * @property featureStates A map containing various feature states. + */ +@Serializable +data class ConfigResponseJson( + @SerialName("object") + val type: String?, + + @SerialName("version") + val version: String?, + + @SerialName("gitHash") + val gitHash: String?, + + @SerialName("server") + val server: ServerJson?, + + @SerialName("environment") + val environment: EnvironmentJson?, + + @SerialName("featureStates") + val featureStates: Map?, +) { + /** + * Represents a server in the configuration response. + * + * @param name The name of the server. + * @param url The URL of the server. + */ + @Serializable + data class ServerJson( + @SerialName("name") + val name: String?, + + @SerialName("url") + val url: String?, + ) + + /** + * Represents the environment details in the configuration response. + * + * @param cloudRegion The cloud region associated with the environment. + * @param vaultUrl The URL of the vault service in the environment. + * @param apiUrl The URL of the API service in the environment. + * @param identityUrl The URL of the identity service in the environment. + * @param notificationsUrl The URL of the notifications service in the environment. + * @param ssoUrl The URL of the single sign-on (SSO) service in the environment. + */ + @Serializable + data class EnvironmentJson( + @SerialName("cloudRegion") + val cloudRegion: String?, + + @SerialName("vault") + val vaultUrl: String?, + + @SerialName("api") + val apiUrl: String?, + + @SerialName("identity") + val identityUrl: String?, + + @SerialName("notifications") + val notificationsUrl: String?, + + @SerialName("sso") + val ssoUrl: String?, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigService.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigService.kt new file mode 100644 index 0000000000..4417910b71 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigService.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.service + +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson + +/** + * Provides an API for querying for app configurations. + */ +interface ConfigService { + + /** + * Fetch app configuration. + */ + suspend fun getConfig(): Result +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceImpl.kt new file mode 100644 index 0000000000..47ec85a70e --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceImpl.kt @@ -0,0 +1,11 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.service + +import com.bitwarden.authenticator.data.platform.datasource.network.api.ConfigApi +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson + +/** + * Default implementation of [ConfigService] for querying for app configurations. + */ +class ConfigServiceImpl(private val configApi: ConfigApi) : ConfigService { + override suspend fun getConfig(): Result = configApi.getConfig() +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepository.kt new file mode 100644 index 0000000000..4128106386 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepository.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import kotlinx.coroutines.flow.StateFlow + +/** + * Provides an API for observing the server config state. + */ +interface ServerConfigRepository { + + /** + * Emits updates that track [ServerConfig]. + */ + val serverConfigStateFlow: StateFlow + + /** + * Gets the state [ServerConfig]. If needed or forced by [forceRefresh], + * updates the values using server side data. + */ + suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryImpl.kt new file mode 100644 index 0000000000..df428e08dd --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryImpl.kt @@ -0,0 +1,65 @@ +package com.bitwarden.authenticator.data.platform.repository + +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +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, + dispatcherManager: DispatcherManager, +) : ServerConfigRepository { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + override val serverConfigStateFlow: StateFlow + get() = configDiskSource + .serverConfigFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = configDiskSource.serverConfig, + ) + + 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 + } + + private companion object { + private const val MINIMUM_CONFIG_SYNC_INTERVAL_SEC: Long = 60 * 60 + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt index a32987ee39..f9f028dba1 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt @@ -2,18 +2,23 @@ package com.bitwarden.authenticator.data.platform.repository.di import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager import com.bitwarden.authenticator.data.platform.manager.DispatcherManager import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepository import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepositoryImpl +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository +import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepositoryImpl import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.data.platform.repository.SettingsRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import java.time.Clock import javax.inject.Singleton /** @@ -40,6 +45,21 @@ object PlatformRepositoryModule { authenticatorSdkSource = authenticatorSdkSource, ) + @Provides + @Singleton + fun provideServerConfigRepository( + configDiskSource: ConfigDiskSource, + configService: ConfigService, + clock: Clock, + dispatcherManager: DispatcherManager, + ): ServerConfigRepository = + ServerConfigRepositoryImpl( + configDiskSource = configDiskSource, + configService = configService, + clock = clock, + dispatcherManager = dispatcherManager, + ) + @Provides @Singleton fun provideFeatureFlagRepo( diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/base/BaseServiceTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/base/BaseServiceTest.kt new file mode 100644 index 0000000000..d99167b15c --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/base/BaseServiceTest.kt @@ -0,0 +1,35 @@ +package com.bitwarden.authenticator.data.platform.base + +import com.bitwarden.authenticator.data.platform.datasource.network.core.ResultCallAdapterFactory +import com.bitwarden.authenticator.data.platform.datasource.network.di.PlatformNetworkModule +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** + * Base class for service tests. Provides common mock web server and retrofit setup. + */ +abstract class BaseServiceTest { + + protected val json = PlatformNetworkModule.providesJson() + + protected val server = MockWebServer().apply { start() } + + protected val url: HttpUrl = server.url("/") + + protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}" + + protected val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(url.toString()) + .addCallAdapterFactory(ResultCallAdapterFactory()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @AfterEach + fun after() { + server.shutdown() + } +} diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceTest.kt new file mode 100644 index 0000000000..40215aa5e7 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/ConfigDiskSourceTest.kt @@ -0,0 +1,116 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk + +import androidx.core.content.edit +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.di.PlatformNetworkModule +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.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 +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`() = + runTest { + 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 JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + ), + ), +) diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeConfigDiskSource.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeConfigDiskSource.kt new file mode 100644 index 0000000000..0d727b381a --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/util/FakeConfigDiskSource.kt @@ -0,0 +1,25 @@ +package com.bitwarden.authenticator.data.platform.datasource.disk.util + +import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.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 + get() = mutableServerConfigFlow + .onSubscription { emit(serverConfig) } + + private val mutableServerConfigFlow = + bufferedMutableSharedFlow(replay = 1) +} diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceTest.kt new file mode 100644 index 0000000000..7ff6ba4283 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/network/service/ConfigServiceTest.kt @@ -0,0 +1,67 @@ +package com.bitwarden.authenticator.data.platform.datasource.network.service + +import com.bitwarden.authenticator.data.platform.base.BaseServiceTest +import com.bitwarden.authenticator.data.platform.datasource.network.api.ConfigApi +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.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 +import retrofit2.create + +class ConfigServiceTest : BaseServiceTest() { + + private val api: ConfigApi = retrofit.create() + private val service = ConfigServiceImpl(api) + + @Test + fun `getConfig should call ConfigApi`() = runTest { + server.enqueue(MockResponse().setBody(CONFIG_RESPONSE_JSON)) + assertEquals(CONFIG_RESPONSE.asSuccess(), service.getConfig()) + } +} + +private const val CONFIG_RESPONSE_JSON = """ +{ + "object": "config", + "version": "1", + "gitHash": "gitHash", + "server": { + "name": "default", + "url": "url" + }, + "environment": { + "cloudRegion": "US", + "vault": "vaultUrl", + "api": "apiUrl", + "identity": "identityUrl", + "notifications": "notificationsUrl", + "sso": "ssoUrl" + }, + "featureStates": { + "feature one": false + } +} +""" +private val CONFIG_RESPONSE = ConfigResponseJson( + type = "config", + version = "1", + gitHash = "gitHash", + server = ConfigResponseJson.ServerJson( + name = "default", + url = "url", + ), + environment = ConfigResponseJson.EnvironmentJson( + cloudRegion = "US", + vaultUrl = "vaultUrl", + apiUrl = "apiUrl", + notificationsUrl = "notificationsUrl", + identityUrl = "identityUrl", + ssoUrl = "ssoUrl", + ), + featureStates = mapOf( + "feature one" to JsonPrimitive(false), + ), +) diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryTest.kt new file mode 100644 index 0000000000..dbb1b41d21 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/ServerConfigRepositoryTest.kt @@ -0,0 +1,169 @@ +package com.bitwarden.authenticator.data.platform.repository + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.platform.base.FakeDispatcherManager +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.disk.util.FakeConfigDiskSource +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.ServerJson +import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import com.bitwarden.authenticator.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 +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 fixedClock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + + private val repository = ServerConfigRepositoryImpl( + configDiskSource = fakeConfigDiskSource, + configService = configService, + clock = fixedClock, + dispatcherManager = fakeDispatcherManager, + ) + + @BeforeEach + fun setUp() { + fakeConfigDiskSource.serverConfig = null + } + + @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 JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(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 JsonPrimitive(true), + "flexible-collections-v-1" to JsonPrimitive(false), + ), +) diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt new file mode 100644 index 0000000000..5a82244208 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt @@ -0,0 +1,58 @@ +package com.bitwarden.authenticator.data.platform.repository.util + +import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.EnvironmentJson +import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson.ServerJson +import com.bitwarden.authenticator.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(SERVER_CONFIG) + + override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? { + if (forceRefresh) { + return SERVER_CONFIG + } + + return serverConfigValue + } + + override val serverConfigStateFlow: StateFlow + 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), + "dummy-boolean" to JsonPrimitive(true), + ), + ), +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8417f97a7c..8879303ce4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ ksp = "2.0.21-1.0.27" mockk = "1.13.13" okhttp = "4.12.0" retrofit = "2.11.0" +retrofitBom = "2.11.0" retrofitKotlinxSerialization = "1.0.0" roboelectric = "4.13" sonarqube = "5.1.0.4882" @@ -120,6 +121,8 @@ square-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" square-okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } square-okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } square-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +square-retrofit-bom = { module = "com.squareup.retrofit2:retrofit-bom", version.ref = "retrofitBom" } +square-retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization" } square-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } zxing-zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } testng = { group = "org.testng", name = "testng", version.ref = "testng" }