From 047ce4f1debcc61dbaf17601e08c3b756b528805 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Mon, 28 Sep 2020 09:57:09 +0200 Subject: [PATCH] Handle App Config uniformly with caching (EXPOSUREAPP-2747) (#1213) * Move AppConfig related code into it's own package. * Use "files" instead of "cache" for storing our AppConfigApiTest. * Add "files" to "cache" migration. * Refactor CDN related code into an independent "environment module". TODO: UnitTests, Fallback behavior even if cached AppConfig is stale. * Further structure refactoring and unit test for app config cache migration. * Added config fallback behavior for all cases + additional tests. * Improve code readability. * Remove test TODO --- .../server => appconfig}/AppConfigApiV1.kt | 2 +- .../appconfig/AppConfigModule.kt | 52 +++++ .../appconfig/AppConfigProvider.kt | 106 +++++++++ .../appconfig/AppConfigStorage.kt | 39 ++++ ...pplicationConfigurationCorruptException.kt | 2 +- ...pplicationConfigurationInvalidException.kt | 2 +- .../diagnosiskeys/DiagnosisKeysModule.kt | 72 +----- .../diagnosiskeys/server/AppConfigServer.kt | 61 ----- .../server/DiagnosisKeyServer.kt | 3 +- .../environment/EnvironmentModule.kt | 7 + .../download/DownloadCDNHomeCountry.kt} | 4 +- .../download/DownloadCDNHttpClient.kt} | 4 +- .../environment/download/DownloadCDNModule.kt | 50 ++++ .../download/DownloadCDNServerUrl.kt} | 4 +- .../ApplicationConfigurationService.kt | 2 +- .../rki/coronawarnapp/update/UpdateChecker.kt | 2 +- .../util/di/ApplicationComponent.kt | 10 +- .../server => appconfig}/AppConfigApiTest.kt | 56 ++--- .../appconfig/AppConfigModuleTest.kt | 48 ++++ .../appconfig/AppConfigServerTest.kt | 213 ++++++++++++++++++ .../appconfig/AppConfigStorageTest.kt | 72 ++++++ .../diagnosiskeys/DiagnosisKeysModuleTest.kt | 22 +- .../server/AppConfigServerTest.kt | 131 ----------- .../server/DiagnosisKeyApiTest.kt | 23 +- .../environment/EnvironmentModuleTest.kt | 17 ++ .../download/DownloadCDNModuleTest.kt | 32 +++ .../ApplicationConfigurationServiceTest.kt | 8 +- 27 files changed, 712 insertions(+), 332 deletions(-) rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server => appconfig}/AppConfigApiV1.kt (85%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{exception => appconfig}/ApplicationConfigurationCorruptException.kt (88%) rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{exception => appconfig}/ApplicationConfigurationInvalidException.kt (88%) delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server/DownloadServerUrl.kt => environment/download/DownloadCDNHomeCountry.kt} (52%) rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server/DownloadHttpClient.kt => environment/download/DownloadCDNHttpClient.kt} (53%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server/DownloadHomeCountry.kt => environment/download/DownloadCDNServerUrl.kt} (53%) rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/{diagnosiskeys/server => appconfig}/AppConfigApiTest.kt (74%) create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt similarity index 85% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiV1.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt index 95a991413..ae8898f1a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiV1.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.diagnosiskeys.server +package de.rki.coronawarnapp.appconfig import okhttp3.ResponseBody import retrofit2.http.GET diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt new file mode 100644 index 000000000..aa295a352 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt @@ -0,0 +1,52 @@ +package de.rki.coronawarnapp.appconfig + +import android.content.Context +import dagger.Module +import dagger.Provides +import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient +import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl +import okhttp3.Cache +import okhttp3.OkHttpClient +import org.joda.time.Duration +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +class AppConfigModule { + + @Singleton + @Provides + fun provideAppConfigApi( + context: Context, + @DownloadCDNHttpClient client: OkHttpClient, + @DownloadCDNServerUrl url: String, + gsonConverterFactory: GsonConverterFactory + ): AppConfigApiV1 { + val cacheSize = 1 * 1024 * 1024L // 1MB + + val cacheDir = File(context.cacheDir, "http_app-config") + val cache = Cache(cacheDir, cacheSize) + + val cachingClient = client.newBuilder().apply { + cache(cache) + connectTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS) + readTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS) + writeTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS) + callTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS) + }.build() + + return Retrofit.Builder() + .client(cachingClient) + .baseUrl(url) + .addConverterFactory(gsonConverterFactory) + .build() + .create(AppConfigApiV1::class.java) + } + + companion object { + private val HTTP_TIMEOUT_APPCONFIG = Duration.standardSeconds(10) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt new file mode 100644 index 000000000..72130ea55 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt @@ -0,0 +1,106 @@ +package de.rki.coronawarnapp.appconfig + +import androidx.annotation.VisibleForTesting +import dagger.Lazy +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry +import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass +import de.rki.coronawarnapp.util.ZipHelper.unzip +import de.rki.coronawarnapp.util.security.VerificationKeys +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppConfigProvider @Inject constructor( + private val appConfigAPI: Lazy<AppConfigApiV1>, + private val verificationKeys: VerificationKeys, + @DownloadCDNHomeCountry private val homeCountry: LocationCode, + private val configStorage: AppConfigStorage +) { + + private val configApi: AppConfigApiV1 + get() = appConfigAPI.get() + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun downloadAppConfig(): ByteArray? { + Timber.tag(TAG).d("Fetching app config.") + var exportBinary: ByteArray? = null + var exportSignature: ByteArray? = null + configApi.getApplicationConfiguration(homeCountry.identifier).byteStream() + .unzip { entry, entryContent -> + if (entry.name == EXPORT_BINARY_FILE_NAME) exportBinary = + entryContent.copyOf() + if (entry.name == EXPORT_SIGNATURE_FILE_NAME) exportSignature = + entryContent.copyOf() + } + if (exportBinary == null || exportSignature == null) { + throw ApplicationConfigurationInvalidException() + } + + if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) { + throw ApplicationConfigurationCorruptException() + } + + return exportBinary!! + } + + private suspend fun getNewAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration? { + val newConfigRaw = try { + downloadAppConfig() + } catch (e: Exception) { + Timber.w(e, "Failed to download latest AppConfig.") + null + } + + val newConfigParsed = try { + tryParseConfig(newConfigRaw) + } catch (e: Exception) { + Timber.w(e, "Failed to parse latest AppConfig.") + null + } + + return newConfigParsed?.also { + Timber.v("Saving new valid config.") + configStorage.appConfigRaw = newConfigRaw + } + } + + private fun getFallback(): ApplicationConfigurationOuterClass.ApplicationConfiguration { + val lastValidConfig = tryParseConfig(configStorage.appConfigRaw) + return if (lastValidConfig != null) { + Timber.d("Using fallback AppConfig.") + lastValidConfig + } else { + Timber.e("No valid fallback AppConfig available.") + throw ApplicationConfigurationInvalidException() + } + } + + suspend fun getAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration = + withContext(Dispatchers.IO) { + val newAppConfig = getNewAppConfig() + + return@withContext if (newAppConfig != null) { + newAppConfig + } else { + Timber.w("No new config available, using last valid.") + getFallback() + } + } + + private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfigurationOuterClass.ApplicationConfiguration? { + Timber.v("Parsing config (size=%dB)", byteArray?.size) + if (byteArray == null) return null + return ApplicationConfigurationOuterClass.ApplicationConfiguration.parseFrom(byteArray) + } + + companion object { + private val TAG = AppConfigProvider::class.java.simpleName + + private const val EXPORT_BINARY_FILE_NAME = "export.bin" + private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt new file mode 100644 index 000000000..49bbca096 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt @@ -0,0 +1,39 @@ +package de.rki.coronawarnapp.appconfig + +import android.content.Context +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppConfigStorage @Inject constructor( + context: Context +) { + private val configDir = File(context.filesDir, "appconfig_storage") + private val configFile = File(configDir, "appconfig") + + var appConfigRaw: ByteArray? + get() { + Timber.v("get() AppConfig") + if (!configFile.exists()) return null + + val value = configFile.readBytes() + Timber.v("Read AppConfig of size %s and date %s", value.size, configFile.lastModified()) + return value + } + set(value) { + Timber.v("set(...) AppConfig: %dB", value?.size) + + if (configDir.mkdirs()) Timber.v("Parent folder created.") + + if (configFile.exists()) { + Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified()) + } + if (value != null) { + configFile.writeBytes(value) + } else { + configFile.delete() + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationCorruptException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt similarity index 88% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationCorruptException.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt index 5cb36f066..51c5dfb89 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationCorruptException.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.exception +package de.rki.coronawarnapp.appconfig import de.rki.coronawarnapp.exception.reporting.ErrorCodes import de.rki.coronawarnapp.exception.reporting.ReportedException diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt similarity index 88% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationInvalidException.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt index eb71a47e2..153435397 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationInvalidException.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.exception +package de.rki.coronawarnapp.appconfig import de.rki.coronawarnapp.exception.reporting.ErrorCodes import de.rki.coronawarnapp.exception.reporting.ReportedException diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt index 88be7abab..213c479c3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt @@ -3,45 +3,24 @@ package de.rki.coronawarnapp.diagnosiskeys import android.content.Context import dagger.Module import dagger.Provides -import dagger.Reusable -import de.rki.coronawarnapp.BuildConfig -import de.rki.coronawarnapp.diagnosiskeys.server.AppConfigApiV1 import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyApiV1 -import de.rki.coronawarnapp.diagnosiskeys.server.DownloadHomeCountry -import de.rki.coronawarnapp.diagnosiskeys.server.DownloadHttpClient -import de.rki.coronawarnapp.diagnosiskeys.server.DownloadServerUrl -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.KeyCacheLegacyDao -import de.rki.coronawarnapp.http.HttpClientDefault +import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient +import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl import de.rki.coronawarnapp.storage.AppDatabase -import okhttp3.Cache -import okhttp3.ConnectionSpec import okhttp3.OkHttpClient -import okhttp3.TlsVersion import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import java.io.File import javax.inject.Singleton @Module class DiagnosisKeysModule { - @Singleton - @DownloadHomeCountry - @Provides - fun provideDiagnosisHomeCountry(): LocationCode = LocationCode("DE") - - @Reusable - @DownloadHttpClient - @Provides - fun cdnHttpClient(@HttpClientDefault defaultHttpClient: OkHttpClient): OkHttpClient = - defaultHttpClient.newBuilder().connectionSpecs(CDN_CONNECTION_SPECS).build() - @Singleton @Provides fun provideDiagnosisKeyApi( - @DownloadHttpClient client: OkHttpClient, - @DownloadServerUrl url: String, + @DownloadCDNHttpClient client: OkHttpClient, + @DownloadCDNServerUrl url: String, gsonConverterFactory: GsonConverterFactory ): DiagnosisKeyApiV1 = Retrofit.Builder() .client(client) @@ -50,52 +29,9 @@ class DiagnosisKeysModule { .build() .create(DiagnosisKeyApiV1::class.java) - @Singleton - @Provides - fun provideAppConfigApi( - context: Context, - @DownloadHttpClient client: OkHttpClient, - @DownloadServerUrl url: String, - gsonConverterFactory: GsonConverterFactory - ): AppConfigApiV1 { - val cacheSize = 1 * 1024 * 1024L // 1MB - val cacheDir = File(context.cacheDir, "http_app-config") - val cache = Cache(cacheDir, cacheSize) - val cachingClient = client.newBuilder().cache(cache).build() - return Retrofit.Builder() - .client(cachingClient) - .baseUrl(url) - .addConverterFactory(gsonConverterFactory) - .build() - .create(AppConfigApiV1::class.java) - } - - @Singleton - @DownloadServerUrl - @Provides - fun provideDownloadServerUrl(): String { - val url = BuildConfig.DOWNLOAD_CDN_URL - if (!url.startsWith("https://")) throw IllegalStateException("Innvalid: $url") - return url - } - @Singleton @Provides fun legacyKeyCacheDao(context: Context): KeyCacheLegacyDao { return AppDatabase.getInstance(context).dateDao() } - - companion object { - private val CDN_CONNECTION_SPECS = listOf( - ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS) - .tlsVersions( - TlsVersion.TLS_1_0, - TlsVersion.TLS_1_1, - TlsVersion.TLS_1_2, - TlsVersion.TLS_1_3 - ) - .allEnabledCipherSuites() - .build() - ) - } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt deleted file mode 100644 index f5bee9979..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt +++ /dev/null @@ -1,61 +0,0 @@ -package de.rki.coronawarnapp.diagnosiskeys.server - -import com.google.protobuf.InvalidProtocolBufferException -import dagger.Lazy -import de.rki.coronawarnapp.exception.ApplicationConfigurationCorruptException -import de.rki.coronawarnapp.exception.ApplicationConfigurationInvalidException -import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass -import de.rki.coronawarnapp.util.ZipHelper.unzip -import de.rki.coronawarnapp.util.security.VerificationKeys -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AppConfigServer @Inject constructor( - private val appConfigAPI: Lazy<AppConfigApiV1>, - private val verificationKeys: VerificationKeys, - @DownloadHomeCountry private val homeCountry: LocationCode -) { - - private val configApi: AppConfigApiV1 - get() = appConfigAPI.get() - - suspend fun downloadAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration = - withContext(Dispatchers.IO) { - Timber.tag(TAG).d("Fetching app config.") - var exportBinary: ByteArray? = null - var exportSignature: ByteArray? = null - configApi.getApplicationConfiguration(homeCountry.identifier).byteStream() - .unzip { entry, entryContent -> - if (entry.name == EXPORT_BINARY_FILE_NAME) exportBinary = - entryContent.copyOf() - if (entry.name == EXPORT_SIGNATURE_FILE_NAME) exportSignature = - entryContent.copyOf() - } - if (exportBinary == null || exportSignature == null) { - throw ApplicationConfigurationInvalidException() - } - - if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) { - throw ApplicationConfigurationCorruptException() - } - - try { - return@withContext ApplicationConfigurationOuterClass.ApplicationConfiguration.parseFrom( - exportBinary - ) - } catch (e: InvalidProtocolBufferException) { - throw ApplicationConfigurationInvalidException() - } - } - - companion object { - private val TAG = AppConfigServer::class.java.simpleName - - private const val EXPORT_BINARY_FILE_NAME = "export.bin" - private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig" - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt index acf0c392b..7114ff634 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.diagnosiskeys.server import dagger.Lazy +import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry import de.rki.coronawarnapp.util.HashExtensions.hashToMD5 import de.rki.coronawarnapp.util.debug.measureTimeMillisWithResult import kotlinx.coroutines.Dispatchers @@ -17,7 +18,7 @@ import javax.inject.Singleton @Singleton class DiagnosisKeyServer @Inject constructor( private val diagnosisKeyAPI: Lazy<DiagnosisKeyApiV1>, - @DownloadHomeCountry private val homeCountry: LocationCode + @DownloadCDNHomeCountry private val homeCountry: LocationCode ) { private val keyApi: DiagnosisKeyApiV1 diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt new file mode 100644 index 000000000..5d2288500 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt @@ -0,0 +1,7 @@ +package de.rki.coronawarnapp.environment + +import dagger.Module +import de.rki.coronawarnapp.environment.download.DownloadCDNModule + +@Module(includes = [DownloadCDNModule::class]) +class EnvironmentModule diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadServerUrl.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHomeCountry.kt similarity index 52% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadServerUrl.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHomeCountry.kt index f347e5333..9ee43e7cf 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadServerUrl.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHomeCountry.kt @@ -1,8 +1,8 @@ -package de.rki.coronawarnapp.diagnosiskeys.server +package de.rki.coronawarnapp.environment.download import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) -annotation class DownloadServerUrl +annotation class DownloadCDNHomeCountry diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHttpClient.kt similarity index 53% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHttpClient.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHttpClient.kt index f9536135c..fd7011178 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHttpClient.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHttpClient.kt @@ -1,8 +1,8 @@ -package de.rki.coronawarnapp.diagnosiskeys.server +package de.rki.coronawarnapp.environment.download import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) -annotation class DownloadHttpClient +annotation class DownloadCDNHttpClient diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt new file mode 100644 index 000000000..67235fe1a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt @@ -0,0 +1,50 @@ +package de.rki.coronawarnapp.environment.download + +import dagger.Module +import dagger.Provides +import dagger.Reusable +import de.rki.coronawarnapp.BuildConfig +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.http.HttpClientDefault +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.TlsVersion +import javax.inject.Singleton + +@Module +class DownloadCDNModule { + + @Reusable + @DownloadCDNHttpClient + @Provides + fun cdnHttpClient(@HttpClientDefault defaultHttpClient: OkHttpClient): OkHttpClient = + defaultHttpClient.newBuilder().connectionSpecs(DOWNLOAD_CDN_CONNECTION_SPECS).build() + + @Singleton + @DownloadCDNServerUrl + @Provides + fun provideDownloadServerUrl(): String { + val url = BuildConfig.DOWNLOAD_CDN_URL + if (!url.startsWith("https://")) throw IllegalStateException("Innvalid: $url") + return url + } + + @Singleton + @DownloadCDNHomeCountry + @Provides + fun provideDiagnosisHomeCountry(): LocationCode = LocationCode("DE") + + companion object { + private val DOWNLOAD_CDN_CONNECTION_SPECS = ConnectionSpec + .Builder(ConnectionSpec.COMPATIBLE_TLS) + .tlsVersions( + TlsVersion.TLS_1_0, + TlsVersion.TLS_1_1, + TlsVersion.TLS_1_2, + TlsVersion.TLS_1_3 + ) + .allEnabledCipherSuites() + .build() + .let { listOf(it) } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHomeCountry.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNServerUrl.kt similarity index 53% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHomeCountry.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNServerUrl.kt index 69aa0f792..03af297db 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHomeCountry.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNServerUrl.kt @@ -1,8 +1,8 @@ -package de.rki.coronawarnapp.diagnosiskeys.server +package de.rki.coronawarnapp.environment.download import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) -annotation class DownloadHomeCountry +annotation class DownloadCDNServerUrl diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt index df4a14e30..4c9e23a42 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt @@ -7,7 +7,7 @@ import de.rki.coronawarnapp.util.di.AppInjector object ApplicationConfigurationService { suspend fun asyncRetrieveApplicationConfiguration(): ApplicationConfiguration { - return AppInjector.component.appConfigServer.downloadAppConfig().let { + return AppInjector.component.appConfigProvider.getAppConfig().let { if (CWADebug.isDebugBuildOrMode) { // TODO: THIS IS A MOCK -> Remove after Backend is providing this information. it.toBuilder() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt index bf416407d..b08ae1e74 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt @@ -6,7 +6,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.startActivity import de.rki.coronawarnapp.BuildConfig import de.rki.coronawarnapp.R -import de.rki.coronawarnapp.exception.ApplicationConfigurationCorruptException +import de.rki.coronawarnapp.appconfig.ApplicationConfigurationCorruptException import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.ui.LauncherActivity diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt index 01f3dca1d..cb87334da 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt @@ -5,10 +5,12 @@ import dagger.Component import dagger.android.AndroidInjector import dagger.android.support.AndroidSupportInjectionModule import de.rki.coronawarnapp.CoronaWarnApplication +import de.rki.coronawarnapp.appconfig.AppConfigModule +import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader -import de.rki.coronawarnapp.diagnosiskeys.server.AppConfigServer import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.environment.EnvironmentModule import de.rki.coronawarnapp.http.HttpModule import de.rki.coronawarnapp.http.ServiceFactory import de.rki.coronawarnapp.nearby.ENFClient @@ -40,7 +42,9 @@ import javax.inject.Singleton DeviceModule::class, ENFModule::class, HttpModule::class, - DiagnosisKeysModule::class + EnvironmentModule::class, + DiagnosisKeysModule::class, + AppConfigModule::class ] ) interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { @@ -58,7 +62,7 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { val keyFileDownloader: KeyFileDownloader val serviceFactory: ServiceFactory - val appConfigServer: AppConfigServer + val appConfigProvider: AppConfigProvider val enfClient: ENFClient diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt similarity index 74% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt index 4db01f22b..24c628143 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt @@ -1,7 +1,7 @@ -package de.rki.coronawarnapp.diagnosiskeys.server +package de.rki.coronawarnapp.appconfig import android.content.Context -import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule +import de.rki.coronawarnapp.environment.download.DownloadCDNModule import de.rki.coronawarnapp.http.HttpModule import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -22,19 +22,19 @@ import java.util.concurrent.TimeUnit class AppConfigApiTest : BaseIOTest() { - @MockK - private lateinit var context: Context + @MockK private lateinit var context: Context private lateinit var webServer: MockWebServer private lateinit var serverAddress: String private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) - private val appConfigCacheDir = File(testDir, "http_app-config") + private val cacheFiles = File(testDir, "cache") + private val cacheDir = File(cacheFiles, "http_app-config") @BeforeEach fun setup() { MockKAnnotations.init(this) - every { context.cacheDir } returns testDir + every { context.cacheDir } returns cacheFiles webServer = MockWebServer() webServer.start() @@ -53,18 +53,18 @@ class AppConfigApiTest : BaseIOTest() { val defaultHttpClient = httpModule.defaultHttpClient() val gsonConverterFactory = httpModule.provideGSONConverter() - return DiagnosisKeysModule().let { - val downloadHttpClient = it.cdnHttpClient(defaultHttpClient) - .newBuilder() - .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)) - .build() - it.provideAppConfigApi( - context = context, - client = downloadHttpClient, - url = serverAddress, - gsonConverterFactory = gsonConverterFactory - ) - } + val cdnHttpClient = DownloadCDNModule() + .cdnHttpClient(defaultHttpClient) + .newBuilder() + .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)) + .build() + + return AppConfigModule().provideAppConfigApi( + context = context, + client = cdnHttpClient, + url = serverAddress, + gsonConverterFactory = gsonConverterFactory + ) } @Test @@ -84,7 +84,7 @@ class AppConfigApiTest : BaseIOTest() { @Test fun `application config download uses cache`() { - appConfigCacheDir.exists() shouldBe false + cacheDir.exists() shouldBe false val api = createAPI() @@ -95,8 +95,8 @@ class AppConfigApiTest : BaseIOTest() { runBlocking { api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" } - appConfigCacheDir.exists() shouldBe true - appConfigCacheDir.listFiles()!!.size shouldBe 3 + cacheDir.exists() shouldBe true + cacheDir.listFiles()!!.size shouldBe 3 webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { method shouldBe "GET" @@ -107,8 +107,8 @@ class AppConfigApiTest : BaseIOTest() { runBlocking { api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" } - appConfigCacheDir.exists() shouldBe true - appConfigCacheDir.listFiles()!!.size shouldBe 3 + cacheDir.exists() shouldBe true + cacheDir.listFiles()!!.size shouldBe 3 webServer.takeRequest(2, TimeUnit.SECONDS) shouldBe null @@ -118,8 +118,8 @@ class AppConfigApiTest : BaseIOTest() { runBlocking { api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" } - appConfigCacheDir.exists() shouldBe true - appConfigCacheDir.listFiles()!!.size shouldBe 3 + cacheDir.exists() shouldBe true + cacheDir.listFiles()!!.size shouldBe 3 webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { method shouldBe "GET" @@ -129,7 +129,7 @@ class AppConfigApiTest : BaseIOTest() { @Test fun `cache is used when connection is flaky`() { - appConfigCacheDir.exists() shouldBe false + cacheDir.exists() shouldBe false val api = createAPI() @@ -140,8 +140,8 @@ class AppConfigApiTest : BaseIOTest() { runBlocking { api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" } - appConfigCacheDir.exists() shouldBe true - appConfigCacheDir.listFiles()!!.size shouldBe 3 + cacheDir.exists() shouldBe true + cacheDir.listFiles()!!.size shouldBe 3 webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply { method shouldBe "GET" diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt new file mode 100644 index 000000000..e9917c952 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt @@ -0,0 +1,48 @@ +package de.rki.coronawarnapp.appconfig + +import android.content.Context +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class AppConfigModuleTest : BaseIOTest() { + @MockK + private lateinit var context: Context + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheFiles = File(testDir, "cache") + private val httpCacheDir = File(cacheFiles, "http_app-config") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.cacheDir } returns cacheFiles + + testDir.mkdirs() + testDir.exists() shouldBe true + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createModule() = AppConfigModule() + + @Test + fun `sideeffect free instantiation`() { + shouldNotThrowAny { + createModule() + } + } + +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt new file mode 100644 index 000000000..31df2ce6a --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt @@ -0,0 +1,213 @@ +package de.rki.coronawarnapp.appconfig + +import dagger.Lazy +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.util.security.VerificationKeys +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.ByteString.Companion.decodeHex +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File +import java.io.IOException + +class AppConfigServerTest : BaseIOTest() { + + @MockK lateinit var api: AppConfigApiV1 + @MockK lateinit var verificationKeys: VerificationKeys + @MockK lateinit var appConfigStorage: AppConfigStorage + + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + + private val defaultHomeCountry = LocationCode("DE") + + private var mockConfigStorage: ByteArray? = null + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + every { appConfigStorage.appConfigRaw } answers { mockConfigStorage } + every { appConfigStorage.appConfigRaw = any() } answers { mockConfigStorage = arg(0) } + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createDownloadServer( + homeCountry: LocationCode = defaultHomeCountry + ) = AppConfigProvider( + appConfigAPI = Lazy { api }, + verificationKeys = verificationKeys, + homeCountry = homeCountry, + configStorage = appConfigStorage + ) + + @Test + fun `application config download`() { + coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + + val downloadServer = createDownloadServer() + + runBlocking { + val rawConfig = downloadServer.downloadAppConfig() + rawConfig shouldBe APPCONFIG_RAW.toByteArray() + } + + verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) } + } + + @Test + fun `application config data is faulty`() { + coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex() + .toResponseBody() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + + val downloadServer = createDownloadServer() + + runBlocking { + shouldThrow<ApplicationConfigurationInvalidException> { + downloadServer.downloadAppConfig() + } + } + } + + @Test + fun `application config verification fails`() { + coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE + .toResponseBody() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns true + + val downloadServer = createDownloadServer() + + runBlocking { + shouldThrow<ApplicationConfigurationCorruptException> { + downloadServer.downloadAppConfig() + } + } + } + + @Test + fun `successful download stores new config`() { + coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE + .toResponseBody() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + + val downloadServer = createDownloadServer() + + runBlocking { + downloadServer.getAppConfig() + + mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray() + verify { appConfigStorage.appConfigRaw = APPCONFIG_RAW.toByteArray() } + } + } + + @Test + fun `failed download doesn't overwrite valid config`() { + mockConfigStorage = APPCONFIG_RAW.toByteArray() + coEvery { api.getApplicationConfiguration("DE") } throws IOException() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + + runBlocking { + createDownloadServer().getAppConfig() + } + + verify(exactly = 0) { appConfigStorage.appConfigRaw = any() } + mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray() + } + + @Test + fun `failed verification doesn't overwrite valid config`() { + mockConfigStorage = APPCONFIG_RAW.toByteArray() + coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE + .toResponseBody() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns true + + runBlocking { + createDownloadServer().getAppConfig() + } + + verify(exactly = 0) { appConfigStorage.appConfigRaw = any() } + mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray() + } + + @Test + fun `fallback to last config if verification fails`() { + mockConfigStorage = APPCONFIG_RAW.toByteArray() + + coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex() + .toResponseBody() + + every { verificationKeys.hasInvalidSignature(any(), any()) } throws Exception() + runBlocking { + createDownloadServer().getAppConfig().minRiskScore shouldBe 11 + } + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns true + runBlocking { + createDownloadServer().getAppConfig().minRiskScore shouldBe 11 + } + } + + @Test + fun `fallback to last config if download fails`() { + mockConfigStorage = APPCONFIG_RAW.toByteArray() + + coEvery { api.getApplicationConfiguration("DE") } throws Exception() + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + + runBlocking { + createDownloadServer().getAppConfig().minRiskScore shouldBe 11 + } + } + + companion object { + private val APPCONFIG_BUNDLE = + ("504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" + + "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" + + "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" + + "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" + + "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" + + "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" + + "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" + + "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" + + "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" + + "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" + + "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" + + "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" + + "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" + + "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" + + "0200020070000000ae0100000000").decodeHex() + private val APPCONFIG_RAW = + ("080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + + "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + + "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + + "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + + "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + + "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804").decodeHex() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt new file mode 100644 index 000000000..aaecb0b01 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt @@ -0,0 +1,72 @@ +package de.rki.coronawarnapp.appconfig + +import android.content.Context +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class AppConfigStorageTest : BaseIOTest() { + + @MockK private lateinit var context: Context + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val privateFiles = File(testDir, "files") + private val storageDir = File(privateFiles, "appconfig_storage") + private val configPath = File(storageDir, "appconfig") + private val testByteArray = "The Cake Is A Lie".toByteArray() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.filesDir } returns privateFiles + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createStorage() = AppConfigStorage(context) + + @Test + fun `simple read and write config`() { + configPath.exists() shouldBe false + val storage = createStorage() + configPath.exists() shouldBe false + + storage.appConfigRaw = testByteArray + + configPath.exists() shouldBe true + configPath.readBytes() shouldBe testByteArray + + storage.appConfigRaw shouldBe testByteArray + } + + @Test + fun `nulling and overwriting`() { + val storage = createStorage() + configPath.exists() shouldBe false + + storage.appConfigRaw shouldBe null + storage.appConfigRaw = null + configPath.exists() shouldBe false + + storage.appConfigRaw shouldBe null + storage.appConfigRaw = testByteArray + storage.appConfigRaw shouldBe testByteArray + configPath.exists() shouldBe true + configPath.readBytes() shouldBe testByteArray + + storage.appConfigRaw = null + storage.appConfigRaw shouldBe null + configPath.exists() shouldBe false + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt index 353312909..6c8049eba 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt @@ -1,23 +1,17 @@ package de.rki.coronawarnapp.diagnosiskeys -import de.rki.coronawarnapp.BuildConfig -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import io.kotest.matchers.shouldBe +import io.kotest.assertions.throwables.shouldNotThrowAny import org.junit.jupiter.api.Test -import testhelpers.BaseIOTest +import testhelpers.BaseTest -class DiagnosisKeysModuleTest : BaseIOTest() { +class DiagnosisKeysModuleTest : BaseTest() { - private val module = DiagnosisKeysModule() + private fun createModule() = DiagnosisKeysModule() @Test - fun `home country should be DE`() { - module.provideDiagnosisHomeCountry() shouldBe LocationCode("DE") + fun `sideeffect free instantiation`() { + shouldNotThrowAny { + createModule() + } } - - @Test - fun `download URL comes from BuildConfig`() { - module.provideDownloadServerUrl() shouldBe BuildConfig.DOWNLOAD_CDN_URL - } - } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt deleted file mode 100644 index 0fbd9e4c8..000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package de.rki.coronawarnapp.diagnosiskeys.server - -import dagger.Lazy -import de.rki.coronawarnapp.exception.ApplicationConfigurationCorruptException -import de.rki.coronawarnapp.exception.ApplicationConfigurationInvalidException -import de.rki.coronawarnapp.util.security.VerificationKeys -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.shouldBe -import io.mockk.MockKAnnotations -import io.mockk.clearAllMocks -import io.mockk.coEvery -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.verify -import kotlinx.coroutines.runBlocking -import okhttp3.ResponseBody.Companion.toResponseBody -import okio.ByteString.Companion.decodeHex -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import testhelpers.BaseIOTest -import java.io.File - -class AppConfigServerTest : BaseIOTest() { - - @MockK - lateinit var api: AppConfigApiV1 - - @MockK - lateinit var verificationKeys: VerificationKeys - - private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) - - private val defaultHomeCountry = LocationCode("DE") - - @BeforeEach - fun setup() { - MockKAnnotations.init(this) - testDir.mkdirs() - testDir.exists() shouldBe true - } - - @AfterEach - fun teardown() { - clearAllMocks() - testDir.deleteRecursively() - } - - private fun createDownloadServer( - homeCountry: LocationCode = defaultHomeCountry - ) = AppConfigServer( - appConfigAPI = Lazy { api }, - verificationKeys = verificationKeys, - homeCountry = homeCountry - ) - - @Test - fun `application config download`() { - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_HEX.decodeHex() - .toResponseBody() - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - val downloadServer = createDownloadServer() - - runBlocking { - val config = downloadServer.downloadAppConfig() - config.apply { - // We just care here that it's non default values, i.e. conversion worked - minRiskScore shouldBe 11 - appVersion.android.latest.major shouldBe 1 - appVersion.android.latest.minor shouldBe 0 - appVersion.android.latest.patch shouldBe 4 - - } - } - - verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) } - } - - @Test - fun `application config data is faulty`() { - coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex() - .toResponseBody() - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - val downloadServer = createDownloadServer() - - runBlocking { - shouldThrow<ApplicationConfigurationInvalidException> { - downloadServer.downloadAppConfig() - } - } - } - - @Test - fun `application config verification fails`() { - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_HEX.decodeHex() - .toResponseBody() - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns true - - val downloadServer = createDownloadServer() - - runBlocking { - shouldThrow<ApplicationConfigurationCorruptException> { - downloadServer.downloadAppConfig() - } - } - } - - companion object { - private const val APPCONFIG_HEX = - "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" + - "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" + - "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" + - "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" + - "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" + - "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" + - "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" + - "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" + - "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" + - "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" + - "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" + - "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" + - "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" + - "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" + - "0200020070000000ae0100000000" - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt index 2c6c522ed..61255ae4d 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.diagnosiskeys.server import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule +import de.rki.coronawarnapp.environment.download.DownloadCDNModule import de.rki.coronawarnapp.http.HttpModule import io.kotest.matchers.shouldBe import kotlinx.coroutines.runBlocking @@ -35,17 +36,17 @@ class DiagnosisKeyApiTest : BaseIOTest() { val defaultHttpClient = httpModule.defaultHttpClient() val gsonConverterFactory = httpModule.provideGSONConverter() - return DiagnosisKeysModule().let { - val downloadHttpClient = it.cdnHttpClient(defaultHttpClient) - .newBuilder() - .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)) - .build() - it.provideDiagnosisKeyApi( - client = downloadHttpClient, - url = serverAddress, - gsonConverterFactory = gsonConverterFactory - ) - } + val cdnHttpClient = DownloadCDNModule() + .cdnHttpClient(defaultHttpClient) + .newBuilder() + .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)) + .build() + + return DiagnosisKeysModule().provideDiagnosisKeyApi( + client = cdnHttpClient, + url = serverAddress, + gsonConverterFactory = gsonConverterFactory + ) } @Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt new file mode 100644 index 000000000..d28841931 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt @@ -0,0 +1,17 @@ +package de.rki.coronawarnapp.environment + +import io.kotest.assertions.throwables.shouldNotThrowAny +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class EnvironmentModuleTest : BaseTest() { + + private fun createModule() = EnvironmentModule() + + @Test + fun `sideeffect free instantiation`() { + shouldNotThrowAny { + createModule() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt new file mode 100644 index 000000000..961f4a2cf --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt @@ -0,0 +1,32 @@ +package de.rki.coronawarnapp.environment.download + +import de.rki.coronawarnapp.BuildConfig +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest + +class DownloadCDNModuleTest : BaseIOTest() { + + private fun createModule() = DownloadCDNModule() + + @Test + fun `sideeffect free instantiation`() { + shouldNotThrowAny { + createModule() + } + } + + @Test + fun `home country should be DE`() { + val module = createModule() + module.provideDiagnosisHomeCountry() shouldBe LocationCode("DE") + } + + @Test + fun `download URL comes from BuildConfig`() { + val module = createModule() + module.provideDownloadServerUrl() shouldBe BuildConfig.DOWNLOAD_CDN_URL + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt index 06f4368a0..eca0ea6cb 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt @@ -1,6 +1,6 @@ package de.rki.coronawarnapp.service.applicationconfiguration -import de.rki.coronawarnapp.diagnosiskeys.server.AppConfigServer +import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.http.WebRequestBuilder import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass import de.rki.coronawarnapp.util.CWADebug @@ -38,12 +38,12 @@ class ApplicationConfigurationServiceTest : BaseTest() { every { appConfigBuilder.build() } returns appConfig - val downloadServer = mockk<AppConfigServer>() - coEvery { downloadServer.downloadAppConfig() } returns appConfig + val downloadServer = mockk<AppConfigProvider>() + coEvery { downloadServer.getAppConfig() } returns appConfig mockkObject(AppInjector) mockk<ApplicationComponent>().apply { - every { this@apply.appConfigServer } returns downloadServer + every { this@apply.appConfigProvider } returns downloadServer every { AppInjector.component } returns this@apply } -- GitLab