diff --git a/.buildconfig.yml b/.buildconfig.yml index 3ccb64e0671..91117d7b1c9 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -188,6 +188,10 @@ projects: path: components/service/fretboard description: 'An Android framework for segmenting users in order to run A/B tests and rollout features gradually.' publish: true + service-experiments: + path: components/service/experiments + description: 'An Android SDK for running experiments on user segments in multiple branches.' + publish: true service-glean: path: components/service/glean description: 'A client-side telemetry SDK for collecting metrics and sending them to the Mozilla telemetry service' diff --git a/CODEOWNERS b/CODEOWNERS index fde49bedc91..17c2b963757 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -23,6 +23,9 @@ /components/service/glean/ @mozilla-mobile/telemetry /samples/glean/ @mozilla-mobile/telemetry +# Experiments library +/components/service/experiments/ @mozilla-mobile/telemetry + # Release Engineering pipeline /automation/ @mozilla-mobile/releng @mozilla-mobile/act /CODEOWNERS @mozilla-mobile/releng @mozilla-mobile/act diff --git a/README.md b/README.md index 3d4f0c4d390..bc413d6475b 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ _Components and libraries to interact with backend services._ * 🔴 [**Glean**](components/service/glean/README.md) - A client-side telemetry SDK for collecting metrics and sending them to Mozilla's telemetry service (eventually replacing [service-telemetry](components/service/telemetry/README.md)). +* 🔴 [**Experiments**](components/service/experiments/README.md) - An Android SDK for running experiments on user segments in multiple branches. + * 🔴 [**Pocket**](components/service/pocket/README.md) - A library for communicating with the Pocket API. * 🔵 [**Telemetry**](components/service/telemetry/README.md) - A generic library for sending telemetry pings from Android applications to Mozilla's telemetry service. diff --git a/components/service/experiments/.gitignore b/components/service/experiments/.gitignore new file mode 100644 index 00000000000..796b96d1c40 --- /dev/null +++ b/components/service/experiments/.gitignore @@ -0,0 +1 @@ +/build diff --git a/components/service/experiments/README.md b/components/service/experiments/README.md new file mode 100644 index 00000000000..b04f48d7890 --- /dev/null +++ b/components/service/experiments/README.md @@ -0,0 +1,254 @@ +# [Android Components](../../../README.md) > Service > Experiments + +An Android SDK for running experiments on user segments in multiple branches. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:service-experiments:{latest-version}" +``` + +### Creating an Experiments instance +In order to use the library, first you have to create a new `Experiments` instance. You do this once per app launch +(typically in your `Application` class `onCreate` method). You simply have to instantiate the `Experiments` class and +provide the `ExperimentStorage` and `ExperimentSource` implementations, like this: + +```Kotlin +class SampleApp : Application() { + lateinit var experiments: Experiments + + override fun onCreate() { + experiments = Experiments( + experimentSource, + experimentStorage + ) + } +} +``` + +#### Using Kinto as experiment source +Experiments includes a default source implementation for a Kinto backend, which you can use like this: + +```Kotlin +// Specify which HTTP (Fetch) client to use +val httpClient = GeckoViewFetchClient(context) + +val experiments = Experiments( + KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient), + experimentStorage +) +``` + +#### Using a JSON file as experiment storage +Experiments includes support for flat JSON files as storage mechanism out of the box: + +```Kotlin +val experiments = Experiments( + experimentSource, + FlatFileExperimentStorage(File(context.filesDir, "experiments.json")) +) +``` + +### Fetching experiments from disk +After instantiating `Experiments`, in order to load the list of already downloaded +experiments from disk, you need to call the `loadExperiments` method (don't call it +on the UI thread, this example uses a coroutine): + +```Kotlin +launch(CommonPool) { + experiments.loadExperiments() +} +``` + +### Updating experiment list +Experiments provides two ways of updating the downloaded experiment list from the server: the first one is to directly +call `updateExperiments` on a `Experiments` instance, which forces experiments to be updated immediately and synchronously +(do not call this on the main thread), like this: + +```Kotlin +experiments.updateExperiments() +``` + +The second one is to use the provided `JobScheduler`-based scheduler, like this: +```Kotlin +val scheduler = JobSchedulerSyncScheduler(context) +scheduler.schedule(EXPERIMENTS_JOB_ID, ComponentName(this, ExperimentsSyncService::class.java)) +``` + +Where `ExperimentsSyncService` is a subclass of `SyncJob` you create like this, providing the `Experiments` instance via the +`getExperiments` method: + +```Kotlin +class ExperimentsSyncService : SyncJob() { + override fun getExperiments(): Experiments { + return experiments + } +} +``` + +And then you have to register it on the manifest, just like any other `JobService`: + +```xml + +``` + +### Checking if a user is part of an experiment +In order to check if a user is part of a specific experiment, Experiments provides two APIs: a Kotlin-friendly +`withExperiment` API and a more Java-like `isInExperiment`. In both cases you pass an instance of `ExperimentDescriptor` +with the name of the experiment you want to check: + +```Kotlin +val descriptor = ExperimentDescriptor("first-experiment-name") +experiments.withExperiment(descriptor) { + someButton.setBackgroundColor(Color.RED) +} + +otherButton.isEnabled = experiments.isInExperiment(descriptor) +``` + +### Getting experiment metadata +Experiments allows experiments to carry associated metadata, which can be retrieved using the Kotlin-friendly +`withExperiment` API or the more Java-like `getExperiment` API, like this: + +```Kotlin +val descriptor = ExperimentDescriptor("first-experiment-name") +experiments.withExperiment(descriptor) { + toolbar.setColor(Color.parseColor(it.payload?.get("color") as String)) +} +textView.setText(experiments.getExperiment(descriptor)?.payload?.get("message")) +``` + +### Setting override values +Experiments allows you to force activate / deactivate a specific experiment via `setOverride`, you +simply have to pass true to activate it, false to deactivate: + +```Kotlin +val descriptor = ExperimentDescriptor("first-experiment-name") +experiments.setOverride(context, descriptor, true) +``` + +You can also clear an override for an experiment or all overrides: + +```Kotlin +val descriptor = ExperimentDescriptor("first-experiment-name") +experiments.clearOverride(context, descriptor) +experiments.clearAllOverrides(context) +``` + +### Filters +Experiments allows you to specify the following filters: +- Buckets: Every user is in one of 100 buckets (0-99). For every experiment you can set up a min and max value (0 <= min <= max <= 100). The bounds are [min, max). + - Both max and min are optional. For example, specifying only min = 0 or only max = 100 includes all users + - 0-100 includes all users (as opposed to 0-99) + - 0-0 includes no users (as opposed to just bucket 0) + - 0-1 includes just bucket 0 + - Users will always stay in the same bucket. An experiment targeting 0-25 will always target the same 25% of users +- appId (regex): The app ID (package name) +- version (regex): The app version +- country (regex): country, pulled from the default locale +- lang (regex): language, pulled from the default locale +- device (regex): Android device name +- manufacturer (regex): Android device manufacturer +- region: custom region, different from the one from the default locale (like a GeoIP, or something similar). +- release channel: release channel of the app (alpha, beta, etc) + +For region and release channel to work you must provide a `ValuesProvider` implementation when creating the `Experiments` instance, as detailed below + +### Specifying custom values for filters +Additionally, Experiments allows you to specify a custom `ValuesProvider` object in order to return a custom region, +different from the one of the current locale (perhaps doing a GeoIP or something like that), or the app +relase channel (alpha, beta, etc). It also allows you to override the values for other experiment properties +(such as the appId, country, etc): + +```Kotlin +val experiments = Experiments( + experimentSource, + experimentStorage, + object : ValuesProvider { + override fun getRegion() { + return custom_region + } + + override fun getReleaseChannel() { + return app_channel + } + } +) +``` + +### Creating a custom experiment source +You can create a custom experiment source simply by implementing the `ExperimentSource` interface: + +```Kotlin +class MyExperimentSource : ExperimentSource { + override fun getExperiments(snapshot: ExperimentsSnapshot): ExperimentsSnapshot { + // ... + return updatedSnapshot + } +} +``` + +The `getExperiments` method takes an `ExperimentsSnapshot` object, which contains the list of already downloaded experiments and +a last_modified date, and returns another `ExperimentsSnapshot` object with the updated list of experiments. + +As the `getExperiments` receives the list of experiments from storage and a last_modified date, it allows you +to do diff requests, if your storage mechanism supports it (like Kinto does). + +### Creating a custom experiment storage +You can create a custom experiment storage simply by implementing the `ExperimentStorage` interface, overriding +the save and retrieve methods, which use `ExperimentsSnapshot` objects with the list of experiments and a last_modified date: + +```Kotlin +class MyExperimentStorage : ExperimentStorage { + override fun save(snapshot: ExperimentsSnapshot) { + // save snapshot to disk + } + + override fun retrieve(): ExperimentsSnapshot { + // load snapshot from disk + return snapshot + } +} +``` + +### Experiments format for Kinto +The provided implementation for Kinto expects the experiments in the following JSON format: +```json +{ + "data":[ + { + "name": "", + "match":{ + "lang":"", + "appId":"", + "regions":[], + "country":"", + "version":"", + "device":"", + "manufacturer":"", + "region":"", + "release_channel":"" + }, + "buckets": { + "min": "0", + "max": "100" + }, + "description":"", + "id":"", + "last_modified":1523549895713 + } + ] +} +``` + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/components/service/experiments/build.gradle b/components/service/experiments/build.gradle new file mode 100644 index 00000000000..067ce0f9e04 --- /dev/null +++ b/components/service/experiments/build.gradle @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion config.compileSdkVersion + + defaultConfig { + minSdkVersion config.minSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation Dependencies.kotlin_stdlib + implementation Dependencies.kotlin_coroutines + implementation Dependencies.arch_workmanager + + implementation project(':concept-fetch') + implementation project(':support-ktx') + implementation project(':support-base') + + testImplementation Dependencies.testing_junit + testImplementation Dependencies.testing_robolectric + testImplementation Dependencies.testing_mockito + testImplementation Dependencies.testing_mockwebserver + testImplementation Dependencies.kotlin_reflect + + testImplementation project(':support-test') + testImplementation project(':lib-fetch-httpurlconnection') +} + +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/components/service/experiments/proguard-rules.pro b/components/service/experiments/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/components/service/experiments/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/components/service/experiments/src/main/AndroidManifest.xml b/components/service/experiments/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..3b94ac66d8f --- /dev/null +++ b/components/service/experiments/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/DeviceUuidFactory.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/DeviceUuidFactory.kt new file mode 100644 index 00000000000..c4fcc45eabf --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/DeviceUuidFactory.kt @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import java.util.UUID + +/** + * Class used to generate a random UUID for the device + * and store it persistent in shared preferences. + * + * If the UUID was already generated it returns the stored one + * + * @param context context + */ +internal class DeviceUuidFactory(context: Context) { + /** + * Unique UUID for the current android device. As with all UUIDs, + * this unique ID is "very highly likely" to be unique across all Android + * devices. Much more so than ANDROID_ID is + * + * The UUID is generated with UUID.randomUUID() + */ + val uuid by lazy { + val preferences = context + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + val prefUuid = preferences.getString(PREF_UUID_KEY, null) + + if (prefUuid != null) { + UUID.fromString(prefUuid).toString() + } else { + val uuid = UUID.randomUUID() + preferences.edit().putString(PREF_UUID_KEY, uuid.toString()).apply() + uuid.toString() + } + } + + companion object { + private const val PREFS_FILE = "mozilla.components.service.experiments" + private const val PREF_UUID_KEY = "device_uuid" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/Experiment.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/Experiment.kt new file mode 100644 index 00000000000..e982721814f --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/Experiment.kt @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Represents an A/B test experiment, + * independent of the underlying + * storage mechanism + */ +data class Experiment( + /** + * Unique identifier of the experiment. Used internally by Kinto. Not exposed to library consumers. + */ + internal val id: String, + /** + * Human-readable name of the experiment + */ + val name: String, + /** + * Detailed description of the experiment + */ + val description: String? = null, + /** + * Filters for enabling the experiment + */ + val match: Matcher? = null, + /** + * Experiment buckets + */ + val bucket: Bucket? = null, + /** + * Last modified date, as a UNIX timestamp + */ + val lastModified: Long? = null, + /** + * Experiment associated metadata + */ + val payload: ExperimentPayload? = null, + /** + * Last time the experiment schema was modified + * (as a UNIX timestamp) + */ + val schema: Long? = null +) { + data class Matcher( + /** + * Language of the device, as a regex + */ + val language: String? = null, + /** + * id (package name) of the expected application, as a regex + */ + val appId: String? = null, + /** + * Regions where the experiment should be enabled + */ + val regions: List? = null, + /** + * Required app version, as a regex + */ + val version: String? = null, + /** + * Required device manufacturer, as a regex + */ + val manufacturer: String? = null, + /** + * Required device model, as a regex + */ + val device: String? = null, + /** + * Required country, as a three-letter abbreviation + */ + val country: String? = null, + /** + * Required app release channel (alpha, beta, ...), as a regex + */ + val releaseChannel: String? = null + ) + + data class Bucket( + /** + * Maximum bucket (exclusive), values from 0 to 100 + */ + val max: Int? = null, + /** + * Minimum bucket (inclusive), values from 0 to 100 + */ + val min: Int? = null + ) + + /** + * Compares experiments by their id + * + * @return true if the two experiments have the same id, false otherwise + */ + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null || other !is Experiment) { + return false + } + return other.id == id + } + + override fun hashCode(): Int { + return id.hashCode() + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentDescriptor.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentDescriptor.kt new file mode 100644 index 00000000000..7ab5e23eeaa --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentDescriptor.kt @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Class used to identify an experiment + * + * @property name experiment name + */ +data class ExperimentDescriptor(val name: String) diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentDownloadException.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentDownloadException.kt new file mode 100644 index 00000000000..1bda6dc13fa --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentDownloadException.kt @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Exception while downloading experiments from the server + */ +class ExperimentDownloadException : Exception { + constructor(message: String?) : super(message) + constructor(cause: Throwable) : super(cause) +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentEvaluator.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentEvaluator.kt new file mode 100644 index 00000000000..6ac36296d6c --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentEvaluator.kt @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Looper +import android.text.TextUtils +import java.util.zip.CRC32 + +/** + * Class used to determine if a specific experiment should be enabled or not + * for the device the app is running in + * + * @property valuesProvider provider for the device's values + */ +@Suppress("TooManyFunctions") +internal class ExperimentEvaluator(private val valuesProvider: ValuesProvider = ValuesProvider()) { + /** + * Determines if a specific experiment should be enabled or not for the device + * + * @param context context + * @param experimentDescriptor experiment descriptor + * @param experiments list of all experiments + * @param userBucket device bucket + * + * @return experiment object if the device is part of the experiment, null otherwise + */ + fun evaluate( + context: Context, + experimentDescriptor: ExperimentDescriptor, + experiments: List, + userBucket: Int = getUserBucket(context) + ): Experiment? { + val experiment = getExperiment(experimentDescriptor, experiments) ?: return null + val isEnabled = isInBucket(userBucket, experiment) && matches(context, experiment) + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE).let { + return if (it.getBoolean(experimentDescriptor.name, isEnabled)) experiment else null + } + } + + /** + * Finds an experiment given its descriptor + * + * @param descriptor experiment descriptor + * @param experiments experiment list + * + * @return found experiment or null + */ + fun getExperiment(descriptor: ExperimentDescriptor, experiments: List): Experiment? { + return experiments.firstOrNull { it.name == descriptor.name } + } + + private fun matches(context: Context, experiment: Experiment): Boolean { + if (experiment.match != null) { + val region = valuesProvider.getRegion(context) + val matchesRegion = !(region != null && + experiment.match.regions != null && + experiment.match.regions.isNotEmpty() && + experiment.match.regions.none { it == region }) + val releaseChannel = valuesProvider.getReleaseChannel(context) + val matchesReleaseChannel = releaseChannel == null || + experiment.match.releaseChannel == null || + releaseChannel == experiment.match.releaseChannel + return matchesRegion && + matchesReleaseChannel && + matchesExperiment(experiment.match.appId, valuesProvider.getAppId(context)) && + matchesExperiment(experiment.match.language, valuesProvider.getLanguage(context)) && + matchesExperiment(experiment.match.country, valuesProvider.getCountry(context)) && + matchesExperiment(experiment.match.version, valuesProvider.getVersion(context)) && + matchesExperiment(experiment.match.manufacturer, valuesProvider.getManufacturer(context)) && + matchesExperiment(experiment.match.device, valuesProvider.getDevice(context)) + } + return true + } + + private fun matchesExperiment(experimentValue: String?, deviceValue: String): Boolean { + return !(experimentValue != null && + !TextUtils.isEmpty(experimentValue) && + !deviceValue.matches(experimentValue.toRegex())) + } + + private fun isInBucket(userBucket: Int, experiment: Experiment): Boolean { + return (experiment.bucket?.min == null || + userBucket >= experiment.bucket.min) && + (experiment.bucket?.max == null || + userBucket < experiment.bucket.max) + } + + fun getUserBucket(context: Context): Int { + val uuid = valuesProvider.getClientId(context) + val crc = CRC32() + crc.update(uuid.toByteArray()) + val checksum = crc.value + return (checksum % MAX_BUCKET).toInt() + } + + /** + * Overrides a specified experiment asynchronously + * + * @param context context + * @param descriptor descriptor of the experiment + * @param active overridden value for the experiment, true to activate it, false to deactivate + */ + fun setOverride(context: Context, descriptor: ExperimentDescriptor, active: Boolean) { + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(descriptor.name, active) + .apply() + } + + /** + * Overrides a specified experiment as a blocking operation + * + * @param context context + * @param descriptor descriptor of the experiment + * @param active overridden value for the experiment, true to activate it, false to deactivate + */ + @SuppressLint("ApplySharedPref") + fun setOverrideNow(context: Context, descriptor: ExperimentDescriptor, active: Boolean) { + require(Looper.myLooper() != Looper.getMainLooper()) { "This cannot be used on the main thread" } + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(descriptor.name, active) + .commit() + } + + /** + * Clears an override for a specified experiment asynchronously + * + * @param context context + * @param descriptor descriptor of the experiment + */ + fun clearOverride(context: Context, descriptor: ExperimentDescriptor) { + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .remove(descriptor.name) + .apply() + } + + /** + * Clears an override for a specified experiment as a blocking operation + * + * @param context context + * @param descriptor descriptor of the experiment + */ + @SuppressLint("ApplySharedPref") + fun clearOverrideNow(context: Context, descriptor: ExperimentDescriptor) { + require(Looper.myLooper() != Looper.getMainLooper()) { "This cannot be used on the main thread" } + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .remove(descriptor.name) + .commit() + } + + /** + * Clears all experiment overrides asynchronously + * + * @param context context + */ + fun clearAllOverrides(context: Context) { + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .apply() + } + + /** + * Clears all experiment overrides as a blocking operation + * + * @param context context + */ + @SuppressLint("ApplySharedPref") + fun clearAllOverridesNow(context: Context) { + require(Looper.myLooper() != Looper.getMainLooper()) { "This cannot be used on the main thread" } + context.getSharedPreferences(OVERRIDES_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + companion object { + private const val MAX_BUCKET = 100L + private const val OVERRIDES_PREF_NAME = "mozilla.components.service.experiments.overrides" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentPayload.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentPayload.kt new file mode 100644 index 00000000000..e2fe1c63820 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentPayload.kt @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Class which represents an experiment associated data + */ +class ExperimentPayload { + private val valuesMap = HashMap() + + /** + * Puts a value into the payload + * + * @param key key + * @param value value to put under the key + */ + fun put(key: String, value: Any) { + valuesMap[key] = value + } + + /** + * Gets a value from the payload + * + * @param key key + * + * @return value under the specified key + */ + fun get(key: String): Any? { + return valuesMap[key] + } + + /** + * Gets all the payload keys + * + * @return set of payload keys + */ + fun getKeys(): Set { + return valuesMap.keys.toSet() + } + + /** + * Gets a value from the payload as a list of Boolean + * + * @param key key + * + * @return value under the specified key as a list of Boolean + */ + @Suppress("UNCHECKED_CAST") + fun getBooleanList(key: String): List? { + return get(key) as List? + } + + /** + * Gets a value from the payload as a list of Int + * + * @param key key + * + * @return value under the specified key as a list of Int + */ + @Suppress("UNCHECKED_CAST") + fun getIntList(key: String): List? { + return get(key) as List? + } + + /** + * Gets a value from the payload as a list of Long + * + * @param key key + * + * @return value under the specified key as a list of Long + */ + @Suppress("UNCHECKED_CAST") + fun getLongList(key: String): List? { + return get(key) as List? + } + + /** + * Gets a value from the payload as a list of Double + * + * @param key key + * + * @return value under the specified key as a list of Double + */ + @Suppress("UNCHECKED_CAST") + fun getDoubleList(key: String): List? { + return get(key) as List? + } + + /** + * Gets a value from the payload as a list of String + * + * @param key key + * + * @return value under the specified key as a list of String + */ + @Suppress("UNCHECKED_CAST") + fun getStringList(key: String): List? { + return get(key) as List? + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentSource.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentSource.kt new file mode 100644 index 00000000000..1fc88e8d671 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentSource.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Represents a location where experiments are stored + * (Kinto, a JSON file on a server, etc) + */ +interface ExperimentSource { + /** + * Requests new experiments from the source, + * parsing the response into experiments + * + * @param client Http client to use, provided by Experiments + * @param snapshot list of already downloaded experiments + * (in order to process a diff response, for example) + * @return modified list of experiments + */ + fun getExperiments(snapshot: ExperimentsSnapshot): ExperimentsSnapshot +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentStorage.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentStorage.kt new file mode 100644 index 00000000000..0fa31d3d3a9 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentStorage.kt @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Represents a location where experiments + * are stored locally on the device + */ +interface ExperimentStorage { + /** + * Stores the given experiments to disk + * + * @param experiments list of experiments to store + */ + fun save(snapshot: ExperimentsSnapshot) + + /** + * Reads experiments from disk + * + * @return experiments from disk + */ + fun retrieve(): ExperimentsSnapshot +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/Experiments.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/Experiments.kt new file mode 100644 index 00000000000..935b88718d4 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/Experiments.kt @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import mozilla.components.support.base.log.logger.Logger + +/** + * Entry point of the library + * + * @property source experiment remote source + * @property storage experiment local storage mechanism + * @param valuesProvider provider for the device's values + */ +@Suppress("TooManyFunctions") +class Experiments( + private val source: ExperimentSource, + private val storage: ExperimentStorage, + valuesProvider: ValuesProvider = ValuesProvider() +) { + @Volatile private var experimentsResult: ExperimentsSnapshot = ExperimentsSnapshot(listOf(), null) + private var experimentsLoaded: Boolean = false + private val evaluator = ExperimentEvaluator(valuesProvider) + private val logger = Logger(LOG_TAG) + + /** + * Provides the list of experiments (active or not) + */ + val experiments: List + get() = experimentsResult.experiments.toList() + + /** + * Loads experiments from local storage + */ + @Synchronized + fun loadExperiments() { + experimentsResult = storage.retrieve() + experimentsLoaded = true + } + + /** + * Requests new experiments from the server and + * saves them to local storage + */ + @Synchronized + fun updateExperiments(): Boolean { + if (!experimentsLoaded) { + loadExperiments() + } + return try { + val serverExperiments = source.getExperiments(experimentsResult) + experimentsResult = serverExperiments + storage.save(serverExperiments) + true + } catch (e: ExperimentDownloadException) { + // Keep using the local experiments + logger.error(e.message, e) + false + } + } + + /** + * Checks if the user is part of + * the specified experiment + * + * @param context context + * @param descriptor descriptor of the experiment to check + * + * @return true if the user is part of the specified experiment, false otherwise + */ + fun isInExperiment(context: Context, descriptor: ExperimentDescriptor): Boolean { + return evaluator.evaluate(context, descriptor, experimentsResult.experiments) != null + } + + /** + * Performs an action if the user is part of the specified experiment + * + * @param context context + * @param descriptor descriptor of the experiment to check + * @param block block of code to be executed if the user is part of the experiment + */ + fun withExperiment(context: Context, descriptor: ExperimentDescriptor, block: (Experiment) -> Unit) { + evaluator.evaluate(context, descriptor, experimentsResult.experiments)?.let { block(it) } + } + + /** + * Gets the metadata associated with the specified experiment, even if the user is not part of it + * + * @param descriptor descriptor of the experiment + * + * @return metadata associated with the experiment + */ + fun getExperiment(descriptor: ExperimentDescriptor): Experiment? { + return evaluator.getExperiment(descriptor, experimentsResult.experiments) + } + + /** + * Provides the list of active experiments + * + * @param context context + * + * @return active experiments + */ + fun getActiveExperiments(context: Context): List { + return experiments.filter { isInExperiment(context, ExperimentDescriptor(it.name)) } + } + + /** + * Provides a map of active/inactive experiments + * + * @param context context + * + * @return map of experiments to A/B state + */ + fun getExperimentsMap(context: Context): Map { + return experiments.associate { + it.name to + isInExperiment(context, ExperimentDescriptor(it.name)) + } + } + + /** + * Overrides a specified experiment asynchronously + * + * @param context context + * @param descriptor descriptor of the experiment + * @param active overridden value for the experiment, true to activate it, false to deactivate + */ + fun setOverride(context: Context, descriptor: ExperimentDescriptor, active: Boolean) { + evaluator.setOverride(context, descriptor, active) + } + + /** + * Overrides a specified experiment as a blocking operation + * + * @exception IllegalArgumentException when called from the main thread + * @param context context + * @param descriptor descriptor of the experiment + * @param active overridden value for the experiment, true to activate it, false to deactivate + */ + fun setOverrideNow(context: Context, descriptor: ExperimentDescriptor, active: Boolean) { + evaluator.setOverrideNow(context, descriptor, active) + } + + /** + * Clears an override for a specified experiment asynchronously + * + * @param context context + * @param descriptor descriptor of the experiment + */ + fun clearOverride(context: Context, descriptor: ExperimentDescriptor) { + evaluator.clearOverride(context, descriptor) + } + + /** + * Clears an override for a specified experiment as a blocking operation + * + * + * @exception IllegalArgumentException when called from the main thread + * @param context context + * @param descriptor descriptor of the experiment + */ + fun clearOverrideNow(context: Context, descriptor: ExperimentDescriptor) { + evaluator.clearOverrideNow(context, descriptor) + } + + /** + * Clears all experiment overrides asynchronously + * + * @param context context + */ + fun clearAllOverrides(context: Context) { + evaluator.clearAllOverrides(context) + } + + /** + * Clears all experiment overrides as a blocking operation + * + * @exception IllegalArgumentException when called from the main thread + * @param context context + */ + fun clearAllOverridesNow(context: Context) { + evaluator.clearAllOverridesNow(context) + } + + /** + * Returns the user bucket number used to determine whether the user + * is in or out of the experiment + * + * @param context context + */ + fun getUserBucket(context: Context): Int { + return evaluator.getUserBucket(context) + } + + companion object { + private const val LOG_TAG = "experiments" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentsSnapshot.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentsSnapshot.kt new file mode 100644 index 00000000000..d52e9a85cec --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ExperimentsSnapshot.kt @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +/** + * Represents an experiment sync result + */ +data class ExperimentsSnapshot( + /** + * Downloaded list of experiments + */ + val experiments: List, + /** + * Last time experiments were modified on the server, as a UNIX timestamp + */ + val lastModified: Long? +) diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/JSONExperimentParser.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/JSONExperimentParser.kt new file mode 100644 index 00000000000..27fee2ec916 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/JSONExperimentParser.kt @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import mozilla.components.support.ktx.android.org.json.putIfNotNull +import mozilla.components.support.ktx.android.org.json.sortKeys +import mozilla.components.support.ktx.android.org.json.toList +import mozilla.components.support.ktx.android.org.json.tryGetInt +import mozilla.components.support.ktx.android.org.json.tryGetLong +import mozilla.components.support.ktx.android.org.json.tryGetString +import org.json.JSONArray +import org.json.JSONObject + +/** + * Default JSON parsing implementation + */ +class JSONExperimentParser { + /** + * Creates an experiment from its json representation + * + * @param jsonObject experiment json object + * @return created experiment + */ + fun fromJson(jsonObject: JSONObject): Experiment { + val bucketsObject: JSONObject? = jsonObject.optJSONObject(BUCKETS_KEY) + val matchObject: JSONObject? = jsonObject.optJSONObject(MATCH_KEY) + val regions: List? = matchObject?.optJSONArray(REGIONS_KEY)?.toList() + val matcher = if (matchObject != null) { + Experiment.Matcher( + matchObject.tryGetString(LANG_KEY), + matchObject.tryGetString(APP_ID_KEY), + regions, + matchObject.tryGetString(VERSION_KEY), + matchObject.tryGetString(MANUFACTURER_KEY), + matchObject.tryGetString(DEVICE_KEY), + matchObject.tryGetString(COUNTRY_KEY), + matchObject.tryGetString(RELEASE_CHANNEL_KEY)) + } else null + val bucket = if (bucketsObject != null) { + Experiment.Bucket(bucketsObject.tryGetInt(MAX_KEY), bucketsObject.tryGetInt(MIN_KEY)) + } else null + val payloadJson: JSONObject? = jsonObject.optJSONObject(PAYLOAD_KEY) + val payload = if (payloadJson != null) jsonToPayload(payloadJson) else null + return Experiment(jsonObject.getString(ID_KEY), + jsonObject.getString(NAME_KEY), + jsonObject.tryGetString(DESCRIPTION_KEY), + matcher, + bucket, + jsonObject.tryGetLong(LAST_MODIFIED_KEY), + payload, + jsonObject.tryGetLong(SCHEMA_KEY)) + } + + /** + * Converts the specified experiment to json + * + * @param experiment experiment to convert + * + * @return json representation of the experiment + */ + fun toJson(experiment: Experiment): JSONObject { + val jsonObject = JSONObject() + val matchObject = matchersToJson(experiment) + val bucketsObject = JSONObject() + bucketsObject.putIfNotNull(MAX_KEY, experiment.bucket?.max?.toString()) + bucketsObject.putIfNotNull(MIN_KEY, experiment.bucket?.min?.toString()) + jsonObject.put(BUCKETS_KEY, bucketsObject) + jsonObject.putIfNotNull(DESCRIPTION_KEY, experiment.description) + jsonObject.put(ID_KEY, experiment.id) + jsonObject.putIfNotNull(LAST_MODIFIED_KEY, experiment.lastModified) + jsonObject.put(MATCH_KEY, matchObject) + jsonObject.putIfNotNull(NAME_KEY, experiment.name) + jsonObject.putIfNotNull(SCHEMA_KEY, experiment.schema) + jsonObject.putIfNotNull(PAYLOAD_KEY, payloadToJson(experiment.payload)) + return jsonObject + } + + private fun matchersToJson(experiment: Experiment): JSONObject { + val matchObject = JSONObject() + matchObject.putIfNotNull(APP_ID_KEY, experiment.match?.appId) + matchObject.putIfNotNull(COUNTRY_KEY, experiment.match?.country) + matchObject.putIfNotNull(DEVICE_KEY, experiment.match?.device) + matchObject.putIfNotNull(LANG_KEY, experiment.match?.language) + matchObject.putIfNotNull(MANUFACTURER_KEY, experiment.match?.manufacturer) + matchObject.putIfNotNull(REGIONS_KEY, experiment.match?.regions?.let { JSONArray(it) }) + matchObject.putIfNotNull(RELEASE_CHANNEL_KEY, experiment.match?.releaseChannel) + matchObject.putIfNotNull(VERSION_KEY, experiment.match?.version) + return matchObject + } + + private fun jsonToPayload(jsonObject: JSONObject): ExperimentPayload { + // For now we decided to support primitive types only + val payload = ExperimentPayload() + for (key in jsonObject.keys()) { + val value = jsonObject.get(key) + if (value !is JSONObject) { + when (value) { + is JSONArray -> payload.put(key, value.toList()) + else -> payload.put(key, value) + } + } + } + return payload + } + + private fun payloadToJson(payload: ExperimentPayload?): JSONObject? { + if (payload == null) { + return null + } + val jsonObject = JSONObject() + for (key in payload.getKeys()) { + val value = payload.get(key) + when (value) { + is List<*> -> jsonObject.put(key, JSONArray(value)) + else -> jsonObject.put(key, value) + } + } + return jsonObject.sortKeys() + } + + companion object { + private const val BUCKETS_KEY = "buckets" + private const val MATCH_KEY = "match" + private const val REGIONS_KEY = "regions" + private const val LANG_KEY = "lang" + private const val APP_ID_KEY = "appId" + private const val VERSION_KEY = "version" + private const val MANUFACTURER_KEY = "manufacturer" + private const val DEVICE_KEY = "device" + private const val COUNTRY_KEY = "country" + private const val RELEASE_CHANNEL_KEY = "release_channel" + private const val MAX_KEY = "max" + private const val MIN_KEY = "min" + private const val ID_KEY = "id" + private const val NAME_KEY = "name" + private const val DESCRIPTION_KEY = "description" + private const val LAST_MODIFIED_KEY = "last_modified" + private const val SCHEMA_KEY = "schema" + private const val PAYLOAD_KEY = "values" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/ValuesProvider.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ValuesProvider.kt new file mode 100644 index 00000000000..6b9cdc020ed --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/ValuesProvider.kt @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import android.os.Build +import java.util.Locale +import java.util.MissingResourceException + +/** + * Class used to provide + * custom filter values + */ +open class ValuesProvider { + /** + * Provides the user's language + * + * @return user's language as a three-letter abbreviation + */ + open fun getLanguage(context: Context): String { + return try { + Locale.getDefault().isO3Language + } catch (e: MissingResourceException) { + Locale.getDefault().language + } + } + + /** + * Provides the app id (package name) + * + * @return app id (package name) + */ + open fun getAppId(context: Context): String { + return context.packageName + } + + /** + * Provides the user's region + * + * @return user's region as a three-letter abbreviation + */ + open fun getRegion(context: Context): String? { + return null + } + + /** + * Provides the app version + * + * @return app version name + */ + open fun getVersion(context: Context): String { + return context.packageManager.getPackageInfo(context.packageName, 0).versionName + } + + /** + * Provides the device manufacturer + * + * @return device manufacturer + */ + open fun getManufacturer(context: Context): String { + return Build.MANUFACTURER + } + + /** + * Provides the device model + * + * @return device model + */ + open fun getDevice(context: Context): String { + return Build.DEVICE + } + + /** + * Provides the user's country + * + * @return user's country, as a three-letter abbreviation + */ + open fun getCountry(context: Context): String { + return try { + Locale.getDefault().isO3Country + } catch (e: MissingResourceException) { + Locale.getDefault().country + } + } + + /** + * Provides the app's release channel (alpha, beta, ...) + * + * @return release channel of the app + */ + open fun getReleaseChannel(context: Context): String? { + return null + } + + /** + * Provides the client ID (UUID) used for bucketing the users. + */ + open fun getClientId(context: Context): String { + return DeviceUuidFactory(context).uuid + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/jobscheduler/JobSchedulerSyncScheduler.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/jobscheduler/JobSchedulerSyncScheduler.kt new file mode 100644 index 00000000000..c471eb9f27f --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/jobscheduler/JobSchedulerSyncScheduler.kt @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.scheduler.jobscheduler + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import java.util.concurrent.TimeUnit + +/** + * Class used to schedule sync of experiment + * configuration from the server + * + * @param context context + */ +class JobSchedulerSyncScheduler(context: Context) { + private val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + + /** + * Schedule sync with the constrains specified + * + * @param jobInfo object with the job constraints + */ + fun schedule(jobInfo: JobInfo) { + jobScheduler.schedule(jobInfo) + } + + /** + * Schedule sync with the default constraints + * (once a day) + * + * @param jobId unique identifier of the job + * @param serviceName object with the service to run + */ + fun schedule(jobId: Int, serviceName: ComponentName) { + val jobInfo = JobInfo.Builder(jobId, serviceName) + .setPeriodic(TimeUnit.DAYS.toMillis(1)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .build() + jobScheduler.schedule(jobInfo) + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/jobscheduler/SyncJob.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/jobscheduler/SyncJob.kt new file mode 100644 index 00000000000..ee2a316685b --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/jobscheduler/SyncJob.kt @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.scheduler.jobscheduler + +import android.app.job.JobParameters +import android.app.job.JobService +import mozilla.components.service.experiments.Experiments +import java.util.concurrent.Executors + +/** + * JobScheduler job used to updating the list of experiments + */ +abstract class SyncJob : JobService() { + private val executor = Executors.newSingleThreadExecutor() + + override fun onStartJob(params: JobParameters): Boolean { + executor.execute { + try { + getExperiments().updateExperiments() + } catch (e: InterruptedException) { + // Cancel thread + } finally { + jobFinished(params, false) + } + } + return true + } + + override fun onStopJob(params: JobParameters?): Boolean { + executor.shutdownNow() + return true + } + + /** + * Used to provide the instance of Experiments + * the app is using + * + * @return current Experiments instance + */ + abstract fun getExperiments(): Experiments +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/workmanager/SyncWorker.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/workmanager/SyncWorker.kt new file mode 100644 index 00000000000..0078fe675d3 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/workmanager/SyncWorker.kt @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.scheduler.workmanager + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import mozilla.components.service.experiments.Experiments + +abstract class SyncWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + return if (experiments.updateExperiments()) Result.success() else Result.retry() + } + + /** + * Used to provide the instance of Experiments + * the app is using + * + * @return current Experiments instance + */ + abstract val experiments: Experiments +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/workmanager/WorkManagerSyncScheduler.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/workmanager/WorkManagerSyncScheduler.kt new file mode 100644 index 00000000000..1c38f520683 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/scheduler/workmanager/WorkManagerSyncScheduler.kt @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.scheduler.workmanager + +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +/** + * Class used to schedule sync of experiment + * configuration from the server using WorkManager + */ +class WorkManagerSyncScheduler { + /** + * Schedule sync with the default constraints + * (once a day and charging) + * + * @param worker worker class + */ + fun schedule( + worker: Class, + interval: Pair = Pair(1, TimeUnit.DAYS) + ) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val syncWork = PeriodicWorkRequest.Builder(worker, interval.first, interval.second) + .addTag(TAG) + .setConstraints(constraints) + .build() + WorkManager.getInstance().enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, syncWork) + } + + companion object { + private const val TAG = "mozilla.components.service.experiments" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/KintoClient.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/KintoClient.kt new file mode 100644 index 00000000000..47defdfed7d --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/KintoClient.kt @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.source.kinto + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.success +import mozilla.components.service.experiments.ExperimentDownloadException +import java.io.IOException + +/** + * Helper class to make it easier to interact with Kinto + * + * @property httpClient http client to use + * @property baseUrl Kinto server url + * @property bucketName name of the bucket to fetch + * @property collectionName name of the collection to fetch + * @property headers headers to provide along with the request + */ +internal class KintoClient( + private val httpClient: Client, + private val baseUrl: String, + private val bucketName: String, + private val collectionName: String, + private val headers: Map? = null +) { + /** + * Returns all records from the collection + * + * @return Kinto response with all records + */ + fun get(): String { + return fetch(recordsUrl()) + } + + /** + * Performs a diff, given the last_modified time + * + * @param lastModified last modified time as a UNIX timestamp + * + * @return Kinto diff response + */ + fun diff(lastModified: Long): String { + return fetch("${recordsUrl()}?_since=$lastModified") + } + + /** + * Gets the collection associated metadata + * + * @return collection metadata + */ + fun getMetadata(): String { + return fetch(collectionUrl()) + } + + @Suppress("TooGenericExceptionCaught", "ThrowsCount") + internal fun fetch(url: String): String { + try { + val headers = MutableHeaders().also { + headers?.forEach { (k, v) -> it.append(k, v) } + } + + val request = Request(url, headers = headers, useCaches = false) + val response = httpClient.fetch(request) + if (!response.success) { + throw ExperimentDownloadException("Status code: ${response.status}") + } + return response.body.string() + } catch (e: IOException) { + throw ExperimentDownloadException(e) + } catch (e: ArrayIndexOutOfBoundsException) { + // On some devices we are seeing an ArrayIndexOutOfBoundsException being thrown + // somewhere inside AOSP/okhttp. + // See: https://github.com/mozilla-mobile/android-components/issues/964 + throw ExperimentDownloadException(e) + } + } + + private fun recordsUrl() = "${collectionUrl()}/records" + private fun collectionUrl() = "$baseUrl/buckets/$bucketName/collections/$collectionName" +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/KintoExperimentSource.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/KintoExperimentSource.kt new file mode 100644 index 00000000000..8dfe64adfa2 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/KintoExperimentSource.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.source.kinto + +import mozilla.components.concept.fetch.Client +import mozilla.components.service.experiments.ExperimentDownloadException +import mozilla.components.service.experiments.ExperimentSource +import mozilla.components.service.experiments.ExperimentsSnapshot +import mozilla.components.service.experiments.JSONExperimentParser +import org.json.JSONException +import org.json.JSONObject + +/** + * Class responsible for fetching and + * parsing experiments from a Kinto server + * + * @property baseUrl Kinto server url + * @property bucketName name of the bucket to fetch + * @property collectionName name of the collection to fetch + * @param httpClient the http client to use. + * @property validateSignature specifies whether or not the signature should be + * validated, defaults to false. + */ +class KintoExperimentSource( + private val baseUrl: String, + private val bucketName: String, + private val collectionName: String, + httpClient: Client, + private val validateSignature: Boolean = false +) : ExperimentSource { + private val kintoClient = KintoClient(httpClient, baseUrl, bucketName, collectionName) + private val signatureVerifier = SignatureVerifier(httpClient, kintoClient) + + override fun getExperiments(snapshot: ExperimentsSnapshot): ExperimentsSnapshot { + val experimentsDiff = getExperimentsDiff(snapshot) + val updatedSnapshot = mergeExperimentsFromDiff(experimentsDiff, snapshot) + if (validateSignature && + !signatureVerifier.validSignature(updatedSnapshot.experiments, updatedSnapshot.lastModified)) { + throw ExperimentDownloadException("Signature verification failed") + } + return updatedSnapshot + } + + private fun getExperimentsDiff(snapshot: ExperimentsSnapshot): String { + val lastModified = snapshot.lastModified + return if (lastModified != null) { + kintoClient.diff(lastModified) + } else { + kintoClient.get() + } + } + + private fun mergeExperimentsFromDiff(experimentsDiff: String, snapshot: ExperimentsSnapshot): ExperimentsSnapshot { + val experiments = snapshot.experiments + var maxLastModified: Long? = snapshot.lastModified + val mutableExperiments = experiments.toMutableList() + val experimentParser = JSONExperimentParser() + try { + val diffJsonObject = JSONObject(experimentsDiff) + val experimentsJsonArray = diffJsonObject.getJSONArray(DATA_KEY) + for (i in 0 until experimentsJsonArray.length()) { + val experimentJsonObject = experimentsJsonArray.getJSONObject(i) + val experiment = mutableExperiments.singleOrNull { it.id == experimentJsonObject.getString(ID_KEY) } + if (experiment != null) { + mutableExperiments.remove(experiment) + } + if (!experimentJsonObject.has(DELETED_KEY)) { + mutableExperiments.add(experimentParser.fromJson(experimentJsonObject)) + } + val lastModifiedDate = experimentJsonObject.getLong(LAST_MODIFIED_KEY) + if (maxLastModified == null || lastModifiedDate > maxLastModified) { + maxLastModified = lastModifiedDate + } + } + } catch (e: JSONException) { + throw ExperimentDownloadException(e) + } + return ExperimentsSnapshot(mutableExperiments, maxLastModified) + } + + companion object { + private const val ID_KEY = "id" + private const val DATA_KEY = "data" + private const val DELETED_KEY = "deleted" + private const val LAST_MODIFIED_KEY = "last_modified" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/SignatureVerifier.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/SignatureVerifier.kt new file mode 100644 index 00000000000..21ed45af531 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/source/kinto/SignatureVerifier.kt @@ -0,0 +1,266 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.source.kinto + +import android.util.Base64 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.service.experiments.Experiment +import mozilla.components.service.experiments.ExperimentDownloadException +import mozilla.components.service.experiments.JSONExperimentParser +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.security.NoSuchProviderException +import java.security.PublicKey +import java.security.Signature +import java.security.SignatureException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Date +import java.util.concurrent.TimeUnit + +/** + * This class is used to validate the signature of the downloaded list of experiments + */ +internal class SignatureVerifier( + private val client: Client, + private val kintoClient: KintoClient, + private val currentDate: Date = Date() +) { + /** + * Checks the signature of an experiment list against the signature found on the + * metadata JSON object, using the certificates found on the x5u field + * + * @param experiments experiment list + * @param lastModified last modified time of the experiment list + * + * @return true if the list of experiments validates against the signature, false otherwise + */ + internal fun validSignature(experiments: List, lastModified: Long?): Boolean { + val sortedExperiments = experiments.sortedBy { it.id } + val resultJson = JSONArray() + val parser = JSONExperimentParser() + for (experiment in sortedExperiments) { + resultJson.put(parser.toJson(experiment)) + } + val metadata: String? = kintoClient.getMetadata() + metadata?.let { + val metadataJson = JSONObject(metadata).getJSONObject(DATA_KEY) + val signatureJson = metadataJson.getJSONObject(SIGNATURE_KEY) + val signature = signatureJson.getString(SIGNATURE_KEY) + val publicKey = getX5U(signatureJson.getString(X5U_KEY)) + val resultJsonString = resultJson.toString().replace("\\/", "/") + val data = "$SIGNATURE_PREFIX{\"data\":$resultJsonString,\"last_modified\":\"$lastModified\"}" + return validSignature(data, signature, publicKey) + } + return true + } + + /** + * Retrieves the public key from the end-entity certificate from the certificate chain + * located on the given URL from the X5U field + * + * @param url url of the certificate chain + * + * @throws ExperimentDownloadException if the certificate chain format is invalid + * + * @return public key of the end-entity certificate + */ + private fun getX5U(url: String): PublicKey { + val certs = ArrayList() + val cf = CertificateFactory.getInstance("X.509") + val response = client.fetch(Request(url)) + response.body.useBufferedReader { + val firstLine = it.readLine() + if (firstLine != "-----BEGIN CERTIFICATE-----") { + throw ExperimentDownloadException("") + } + var certPem = firstLine + certPem += '\n' + + it.readLines().forEach { line -> + certPem += line + certPem += '\n' + if (line == "-----END CERTIFICATE-----") { + val cert = cf.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) + certs.add(cert as X509Certificate) + certPem = "" + } + } + if (certs.count() < MIN_CERTIFICATES) { + throw ExperimentDownloadException("The chain must contain at least 2 certificates") + } + verifyCertChain(certs) + } + return certs[0].publicKey + } + + /** + * Verifies the validity of the certificates on the chain + * + * @param certificates certificates + * + * @throws ExperimentDownloadException if any certificate is not valid + */ + private fun verifyCertChain(certificates: List) { + for (i in 0 until certificates.count()) { + val cert = certificates[i] + if (!isCertValid(cert)) { + throw ExperimentDownloadException("Certificate expired or not yet valid") + } + if ((i + 1) == certificates.count()) { + verifyRoot(cert) + } else { + verifyCertSignedByParent(cert, i, certificates) + } + } + } + + /** + * Verifies that the certificate is signed by its parent on the chain + * + * @param certificate certificate + * @param index index of the certificate on the chain + * @param certificates certificate chain + * + * @throws ExperimentDownloadException if the certificate chain is invalid + * + * @return true if the certificate is signed by its parent on the chain, false otherwise + */ + private fun verifyCertSignedByParent( + certificate: X509Certificate, + index: Int, + certificates: List + ) { + try { + certificate.verify(certificates[index + 1].publicKey) + } catch (e: CertificateException) { + invalidCertChain(e) + } catch (e: NoSuchAlgorithmException) { + invalidCertChain(e) + } catch (e: InvalidKeyException) { + invalidCertChain(e) + } catch (e: NoSuchProviderException) { + invalidCertChain(e) + } catch (e: SignatureException) { + invalidCertChain(e) + } + } + + private fun invalidCertChain(e: Exception) { + throw ExperimentDownloadException(e) + } + + /** + * Checks the certificate validity. It checks against a window of 30 days + * + * @param certificate certificate to check + * + * @return true if the certificate is still valid (with a 30-day window), false otherwise + */ + private fun isCertValid(certificate: X509Certificate): Boolean { + val notBefore = certificate.notBefore + val notAfter = certificate.notAfter + return currentDate.time >= (notBefore.time - TimeUnit.DAYS.toMillis(CERT_VALIDITY_ROOM_DAYS)) && + (notAfter.time + TimeUnit.DAYS.toMillis(CERT_VALIDITY_ROOM_DAYS)) >= currentDate.time + } + + /** + * Verifies the root certificate of the chain, checking that the issuer matches the subject + * + * @param certificate root certificate + * + * @throws ExperimentDownloadException if the root certificate fails the verification + */ + private fun verifyRoot(certificate: X509Certificate) { + val subject = certificate.subjectDN.name + val issuer = certificate.issuerDN.name + if (subject != issuer) { + throw ExperimentDownloadException("subject does not match issuer") + } + } + + /** + * Validates the signature of a signed data, given a signature and a public key using ECDSA + * with curve P-384 and SHA-384 + + * @param signedData signed data + * @param signature signature + * @param publicKey public key + * + * @return true if the signed data validates against the signature + */ + private fun validSignature(signedData: String, signature: String, publicKey: PublicKey): Boolean { + val dsa = Signature.getInstance("SHA384withECDSA") + dsa.initVerify(publicKey) + dsa.update(signedData.toByteArray(StandardCharsets.UTF_8)) + val signatureBytes = Base64.decode(signature.replace("-", "+").replace("_", "/"), 0) + return dsa.verify(signatureToASN1(signatureBytes)) + } + + /** + * Converts signature bytes into the ASN1 DER format. The provided bytes contain r and s values + * concatenated, and the implementation converts these two values into the DER format + + * @param signatureBytes signatures bytes, r and s values concatenated + * + * @throws ExperimentDownloadException if the signature is not valid + * + * @return DER-encoded signature bytes + */ + private fun signatureToASN1(signatureBytes: ByteArray): ByteArray { + if (signatureBytes.count() == 0 || signatureBytes.count() % 2 != 0) { + throw ExperimentDownloadException("Invalid signature") + } + var rBytes = ByteArray(signatureBytes.count() / 2) + for (i in 0 until signatureBytes.count() / 2) { + rBytes += signatureBytes[i] + } + var sBytes = ByteArray(signatureBytes.count() / 2) + for (i in signatureBytes.count() / 2 until signatureBytes.count()) { + sBytes += signatureBytes[i] + } + val r = BigInteger(rBytes) + val s = BigInteger(sBytes) + rBytes = r.toByteArray() + sBytes = s.toByteArray() + val res = ByteArray(NUMBER_OF_DER_ADDITIONAL_BYTES + rBytes.size + sBytes.size) + res[START_POS] = FIRST_DER_NUMBER + res[B1_POS] = (REMAINING_DER_ADDITIONAL_BYTES + rBytes.size + sBytes.size).toByte() + res[SECOND_NUMBER_POS] = SECOND_DER_NUMBER + res[B2_POS] = rBytes.size.toByte() + System.arraycopy(rBytes, START_POS, res, R_POS, rBytes.size) + res[R_POS + rBytes.size] = SECOND_DER_NUMBER + res[B3_POS + rBytes.size] = sBytes.size.toByte() + System.arraycopy(sBytes, START_POS, res, S_POS + rBytes.size, sBytes.size) + return res + } + + companion object { + private const val DATA_KEY = "data" + private const val SIGNATURE_KEY = "signature" + private const val X5U_KEY = "x5u" + private const val CERT_VALIDITY_ROOM_DAYS = 30L + private const val MIN_CERTIFICATES = 2 + private const val SIGNATURE_PREFIX = "Content-Signature:\u0000" + private const val NUMBER_OF_DER_ADDITIONAL_BYTES = 6 + private const val FIRST_DER_NUMBER: Byte = 0x30 + private const val REMAINING_DER_ADDITIONAL_BYTES = 4 + private const val SECOND_DER_NUMBER: Byte = 0x02 + private const val START_POS = 0 + private const val B1_POS = 1 + private const val SECOND_NUMBER_POS = 2 + private const val B2_POS = 3 + private const val R_POS = 4 + private const val B3_POS = 5 + private const val S_POS = 6 + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/storage/flatfile/ExperimentsSerializer.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/storage/flatfile/ExperimentsSerializer.kt new file mode 100644 index 00000000000..07e88461517 --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/storage/flatfile/ExperimentsSerializer.kt @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.storage.flatfile + +import mozilla.components.service.experiments.Experiment +import mozilla.components.service.experiments.JSONExperimentParser +import mozilla.components.service.experiments.ExperimentsSnapshot +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Helper class for serializing experiments + * into the json format used to save them + * locally on the device + */ +internal class ExperimentsSerializer { + /** + * Transforms the given list of experiments to + * its json file representation + * + * @param snapshot experiment snapshot to serialize + * + * @return json file representation of the given experiments + */ + fun toJson(snapshot: ExperimentsSnapshot): String { + val experimentsJson = JSONObject() + val experimentsJSONArray = JSONArray() + val jsonParser = JSONExperimentParser() + for (experiment in snapshot.experiments) + experimentsJSONArray.put(jsonParser.toJson(experiment)) + experimentsJson.put(EXPERIMENTS_KEY, experimentsJSONArray) + experimentsJson.put(LAST_MODIFIED_KEY, snapshot.lastModified) + return experimentsJson.toString() + } + + /** + * Creates a list of experiments given json + * representation, as stored locally inside a file + * + * @param json json file contents + * + * @return experiment snapshot with the parsed list of experiments + */ + @Throws(JSONException::class) + fun fromJson(json: String): ExperimentsSnapshot { + val experimentsJson = JSONObject(json) + val experimentsJsonArray = experimentsJson.getJSONArray(EXPERIMENTS_KEY) + val experiments = ArrayList() + val jsonParser = JSONExperimentParser() + for (i in 0 until experimentsJsonArray.length()) + experiments.add(jsonParser.fromJson(experimentsJsonArray[i] as JSONObject)) + val lastModified = if (experimentsJson.has(LAST_MODIFIED_KEY)) { + experimentsJson.getLong(LAST_MODIFIED_KEY) + } else { + null + } + return ExperimentsSnapshot(experiments, lastModified) + } + + companion object { + private const val EXPERIMENTS_KEY = "experiments" + private const val LAST_MODIFIED_KEY = "last_modified" + } +} diff --git a/components/service/experiments/src/main/java/mozilla/components/service/experiments/storage/flatfile/FlatFileExperimentStorage.kt b/components/service/experiments/src/main/java/mozilla/components/service/experiments/storage/flatfile/FlatFileExperimentStorage.kt new file mode 100644 index 00000000000..f01c8c0294e --- /dev/null +++ b/components/service/experiments/src/main/java/mozilla/components/service/experiments/storage/flatfile/FlatFileExperimentStorage.kt @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.storage.flatfile + +import android.util.AtomicFile +import mozilla.components.service.experiments.ExperimentStorage +import mozilla.components.service.experiments.ExperimentsSnapshot +import org.json.JSONException +import java.io.FileNotFoundException +import java.io.File +import java.io.IOException + +/** + * Class which uses a flat JSON file as an experiment storage mechanism + * + * @param file file where to store experiments + */ +class FlatFileExperimentStorage(file: File) : ExperimentStorage { + private val atomicFile: AtomicFile = AtomicFile(file) + + override fun retrieve(): ExperimentsSnapshot { + return try { + val experimentsJson = String(atomicFile.readFully()) + ExperimentsSerializer().fromJson(experimentsJson) + } catch (e: FileNotFoundException) { + ExperimentsSnapshot(listOf(), null) + } catch (e: JSONException) { + // The JSON we read from disk is corrupt. There's nothing we can do here and therefore + // we just continue as if the file wouldn't exist. + ExperimentsSnapshot(listOf(), null) + } + } + + override fun save(snapshot: ExperimentsSnapshot) { + val experimentsJson = ExperimentsSerializer().toJson(snapshot) + + val stream = atomicFile.startWrite() + try { + stream.writer().use { + it.append(experimentsJson) + } + + atomicFile.finishWrite(stream) + } catch (e: IOException) { + atomicFile.failWrite(stream) + } + } +} diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/DeviceUuidFactoryTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/DeviceUuidFactoryTest.kt new file mode 100644 index 00000000000..35ee7e8cf71 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/DeviceUuidFactoryTest.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DeviceUuidFactoryTest { + @Test + fun uuidNoPreference() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + val editor = mock(SharedPreferences.Editor::class.java) + `when`(editor.putString(anyString(), any())).thenReturn(editor) + `when`(sharedPreferences.edit()).thenReturn(editor) + `when`(sharedPreferences.getString(eq("device_uuid"), ArgumentMatchers.any())).thenReturn(null) + `when`(context.getSharedPreferences(anyString(), anyInt())).thenReturn(sharedPreferences) + val uuid = DeviceUuidFactory(context).uuid + verify(editor).putString("device_uuid", uuid) + } + + @Test + fun uuidSavedInPreferences() { + val savedUuid = "99111a0f-ca5d-4de1-913a-daba905c53b2" + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getString(eq("device_uuid"), any())).thenReturn(savedUuid) + `when`(context.getSharedPreferences(anyString(), anyInt())).thenReturn(sharedPreferences) + assertEquals(savedUuid, DeviceUuidFactory(context).uuid) + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentEvaluatorTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentEvaluatorTest.kt new file mode 100644 index 00000000000..34651b8b025 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentEvaluatorTest.kt @@ -0,0 +1,602 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ExperimentEvaluatorTest { + @Test + fun evaluateEmtpyMatchers() { + val experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + appId = "", + regions = listOf() + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("other.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateBuckets() { + val experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 21)) + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 69)) + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 19)) + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 70)) + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 71)) + } + + @Test + fun evaluateAppId() { + val experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "^test$", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("other.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + `when`(context.packageName).thenReturn("test") + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateLanguage() { + var experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "esp", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateCountry() { + var experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + + experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "ESP" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateVersion() { + val experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + + packageInfo.versionName = "other.version" + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateDevice() { + var experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + + experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "otherdevice", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateReleaseChannel() { + val experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA", + "alpha" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + var evaluator = ExperimentEvaluator(object : ValuesProvider() { + override fun getReleaseChannel(context: Context): String? { + return "alpha" + } + }) + + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + + evaluator = ExperimentEvaluator(object : ValuesProvider() { + override fun getRegion(context: Context): String? { + return "production" + } + }) + + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateManufacturer() { + var experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val evaluator = ExperimentEvaluator() + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + + experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "other", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateRegion() { + val experiment = Experiment( + "testid", + "testexperiment", + "testdesc", + Experiment.Matcher( + "eng", + "test.appId", + listOf("USA", "GBR"), + "test.version", + "unknown", + "robolectric", + "USA" + ), + Experiment.Bucket( + 70, + 20 + ), + 1528916183) + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("testexperiment"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val packageManager = mock(PackageManager::class.java) + val packageInfo = PackageInfo() + packageInfo.versionName = "test.version" + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + var evaluator = ExperimentEvaluator(object : ValuesProvider() { + override fun getRegion(context: Context): String? { + return "USA" + } + }) + + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + + evaluator = ExperimentEvaluator(object : ValuesProvider() { + override fun getRegion(context: Context): String? { + return "ESP" + } + }) + + assertNull(evaluator.evaluate(context, ExperimentDescriptor("testexperiment"), listOf(experiment), 20)) + } + + @Test + fun evaluateActivateOverride() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("id"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val evaluator = ExperimentEvaluator() + val experiment = Experiment("id", name = "name", bucket = Experiment.Bucket(100, 0)) + assertNull(evaluator.evaluate(context, ExperimentDescriptor("name"), listOf(experiment), -1)) + `when`(sharedPreferences.getBoolean(eq("name"), anyBoolean())).thenReturn(true) + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("name"), listOf(experiment), -1)) + } + + @Test + fun evaluateDeactivateOverride() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + `when`(sharedPreferences.getBoolean(eq("name"), anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val evaluator = ExperimentEvaluator() + val experiment = Experiment("id", name = "name", bucket = Experiment.Bucket(100, 0)) + assertNotNull(evaluator.evaluate(context, ExperimentDescriptor("name"), listOf(experiment), 50)) + `when`(sharedPreferences.getBoolean(eq("name"), anyBoolean())).thenReturn(false) + assertNull(evaluator.evaluate(context, ExperimentDescriptor("name"), listOf(experiment), 50)) + } + + @Test + fun evaluateNoExperimentSameAsDescriptor() { + val savedExperiment = Experiment("wrongid", name = "wrongname") + val descriptor = ExperimentDescriptor("testname") + val context = mock(Context::class.java) + assertNull(ExperimentEvaluator().evaluate(context, descriptor, listOf(savedExperiment), 20)) + } + + @Test + fun setOverrideActivate() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + val sharedPreferencesEditor = mock(SharedPreferences.Editor::class.java) + `when`(sharedPreferencesEditor.putBoolean(anyString(), anyBoolean())).thenReturn(sharedPreferencesEditor) + `when`(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val evaluator = ExperimentEvaluator() + evaluator.setOverride(context, ExperimentDescriptor("exp-name"), true) + verify(sharedPreferencesEditor).putBoolean("exp-name", true) + } + + @Test + fun setOverrideDeactivate() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + val sharedPreferencesEditor = mock(SharedPreferences.Editor::class.java) + `when`(sharedPreferencesEditor.putBoolean(eq("exp-2-name"), anyBoolean())).thenReturn(sharedPreferencesEditor) + `when`(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val evaluator = ExperimentEvaluator() + evaluator.setOverride(context, ExperimentDescriptor("exp-2-name"), false) + verify(sharedPreferencesEditor).putBoolean("exp-2-name", false) + } + + @Test + fun clearOverride() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + val sharedPreferencesEditor = mock(SharedPreferences.Editor::class.java) + `when`(sharedPreferencesEditor.remove(anyString())).thenReturn(sharedPreferencesEditor) + `when`(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val evaluator = ExperimentEvaluator() + evaluator.clearOverride(context, ExperimentDescriptor("exp-name")) + verify(sharedPreferencesEditor).remove("exp-name") + } + + @Test + fun clearAllOverrides() { + val context = mock(Context::class.java) + val sharedPreferences = mock(SharedPreferences::class.java) + val sharedPreferencesEditor = mock(SharedPreferences.Editor::class.java) + `when`(sharedPreferencesEditor.clear()).thenReturn(sharedPreferencesEditor) + `when`(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + `when`(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences) + val evaluator = ExperimentEvaluator() + evaluator.clearAllOverrides(context) + verify(sharedPreferencesEditor).clear() + } + + @Test + fun overridingClientId() { + val evaluator1 = ExperimentEvaluator(object : ValuesProvider() { + override fun getClientId(context: Context): String = "c641eacf-c30c-4171-b403-f077724e848a" + }) + + assertEquals(79, evaluator1.getUserBucket(RuntimeEnvironment.application)) + + val evaluator2 = ExperimentEvaluator(object : ValuesProvider() { + override fun getClientId(context: Context): String = "01a15650-9a5d-4383-a7ba-2f047b25c620" + }) + + assertEquals(55, evaluator2.getUserBucket(RuntimeEnvironment.application)) + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentPayloadTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentPayloadTest.kt new file mode 100644 index 00000000000..dfb76c9c316 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentPayloadTest.kt @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ExperimentPayloadTest { + @Test + fun get() { + val payload = ExperimentPayload() + payload.put("key", "value") + assertEquals("value", payload.get("key")) + assertNull("other", payload.get("other")) + } + + @Test + fun getKeys() { + val payload = ExperimentPayload() + payload.put("first-key", "first-value") + payload.put("second-key", "second-value") + val keys = payload.getKeys() + assertEquals(2, keys.size) + assertTrue(keys.contains("first-key")) + assertTrue(keys.contains("second-key")) + } + + @Test + fun getBooleanList() { + val payload = ExperimentPayload() + payload.put("boolean-key", listOf(true, false)) + assertEquals(listOf(true, false), payload.getBooleanList("boolean-key")) + } + + @Test(expected = ClassCastException::class) + fun getBooleanListInvalidType() { + val payload = ExperimentPayload() + payload.put("boolean-key", "other-value") + payload.getBooleanList("boolean-key") + } + + @Test + fun getIntList() { + val payload = ExperimentPayload() + payload.put("int-key", listOf(1, 2)) + assertEquals(listOf(1, 2), payload.getIntList("int-key")) + } + + @Test(expected = ClassCastException::class) + fun getIntListInvalidType() { + val payload = ExperimentPayload() + payload.put("int-key", "other-value") + payload.getIntList("int-key") + } + + @Test + fun getLongList() { + val payload = ExperimentPayload() + payload.put("long-key", listOf(1L, 2L)) + assertEquals(listOf(1L, 2L), payload.getLongList("long-key")) + } + + @Test(expected = ClassCastException::class) + fun getLongListInvalidType() { + val payload = ExperimentPayload() + payload.put("long-key", "other-value") + payload.getLongList("long-key") + } + + @Test + fun getDoubleList() { + val payload = ExperimentPayload() + payload.put("double-key", listOf(1, 2.5)) + assertEquals(listOf(1, 2.5), payload.getDoubleList("double-key")) + } + + @Test(expected = ClassCastException::class) + fun getDoubleListInvalidType() { + val payload = ExperimentPayload() + payload.put("double-key", "other-value") + payload.getDoubleList("double-key") + } + + @Test + fun getStringList() { + val payload = ExperimentPayload() + payload.put("string-key", listOf("first", "second")) + assertEquals(listOf("first", "second"), payload.getStringList("string-key")) + } + + @Test(expected = ClassCastException::class) + fun getStringListInvalidType() { + val payload = ExperimentPayload() + payload.put("string-key", "other-value") + payload.getStringList("string-key") + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentTest.kt new file mode 100644 index 00000000000..8ac29520c19 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentTest.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ExperimentTest { + @Test + fun testEquals() { + val experiment = Experiment( + "id", + "name", + "description", + null, + null, + 12345, + null) + assertTrue(experiment == experiment) + assertFalse(experiment.equals(null)) + assertFalse(experiment.equals(3)) + val secondExperiment = Experiment( + "id", + "name2", + "description2", + Experiment.Matcher("eng"), + Experiment.Bucket(100, 0), + null, + null + ) + assertTrue(secondExperiment == experiment) + } + + @Test + fun testHashCode() { + val experiment = Experiment("id", "name") + assertEquals(experiment.id.hashCode(), experiment.hashCode()) + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentsTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentsTest.kt new file mode 100644 index 00000000000..2841a1f10af --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ExperimentsTest.kt @@ -0,0 +1,581 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import mozilla.components.service.experiments.storage.flatfile.FlatFileExperimentStorage +import mozilla.components.support.test.any +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.`when` +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File +import kotlin.reflect.full.functions +import kotlin.reflect.jvm.isAccessible + +@RunWith(RobolectricTestRunner::class) +class ExperimentsTest { + @Test + fun loadExperiments() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + verify(experimentStorage).retrieve() + } + + @Test + fun updateExperimentsStorageNotLoaded() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val instance = Experiments(experimentSource, experimentStorage) + instance.updateExperiments() + verify(experimentStorage, times(1)).retrieve() + instance.updateExperiments() + verify(experimentStorage, times(1)).retrieve() + } + + @Test + fun updateExperimentsEmptyStorage() { + val experimentSource = mock(ExperimentSource::class.java) + val result = ExperimentsSnapshot(listOf(), null) + `when`(experimentSource.getExperiments(result)).thenReturn(ExperimentsSnapshot(listOf(Experiment("id", "name")), null)) + val experimentStorage = mock(ExperimentStorage::class.java) + `when`(experimentStorage.retrieve()).thenReturn(result) + val instance = Experiments(experimentSource, experimentStorage) + instance.updateExperiments() + verify(experimentSource).getExperiments(result) + verify(experimentStorage).save(ExperimentsSnapshot(listOf(Experiment("id", "name")), null)) + } + + @Test + fun updateExperimentsFromStorage() { + val experimentSource = mock(ExperimentSource::class.java) + `when`(experimentSource.getExperiments(ExperimentsSnapshot(listOf(Experiment("id0", "name0")), null))).thenReturn(ExperimentsSnapshot(listOf(Experiment("id", "name")), null)) + val experimentStorage = mock(ExperimentStorage::class.java) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(listOf(Experiment("id0", "name0")), null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.updateExperiments() + verify(experimentSource).getExperiments(ExperimentsSnapshot(listOf(Experiment("id0", "name0")), null)) + verify(experimentStorage).save(ExperimentsSnapshot(listOf(Experiment("id", "name")), null)) + } + + @Test + fun experiments() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", "first-name"), + Experiment("second-id", "second-name") + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + var returnedExperiments = instance.experiments + assertEquals(0, returnedExperiments.size) + instance.loadExperiments() + returnedExperiments = instance.experiments + assertEquals(2, returnedExperiments.size) + assertTrue(returnedExperiments.contains(experiments[0])) + assertTrue(returnedExperiments.contains(experiments[1])) + } + + @Test + fun experimentsNoExperiments() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf() + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + val returnedExperiments = instance.experiments + assertEquals(0, returnedExperiments.size) + } + + @Test + fun getActiveExperiments() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + manufacturer = "manufacturer-1" + ) + ), + Experiment("second-id", + name = "second-name", + match = Experiment.Matcher( + manufacturer = "unknown", + appId = "test.appId" + ) + ), + Experiment("third-id", + name = "third-name", + match = Experiment.Matcher( + manufacturer = "unknown", + version = "version.name" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val activeExperiments = instance.getActiveExperiments(context) + assertEquals(2, activeExperiments.size) + assertTrue(activeExperiments.any { it.id == "second-id" }) + assertTrue(activeExperiments.any { it.id == "third-id" }) + } + + @Test + fun getExperimentsMap() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + manufacturer = "manufacturer-1" + ) + ), + Experiment("second-id", + name = "second-name", + match = Experiment.Matcher( + manufacturer = "unknown", + appId = "test.appId" + ) + ), + Experiment("third-id", + name = "third-name", + match = Experiment.Matcher( + manufacturer = "unknown", + version = "version.name" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + val experimentsMap = instance.getExperimentsMap(context) + assertEquals(3, experimentsMap.size) + println(experimentsMap.toString()) + assertTrue(experimentsMap["first-name"] == false) + assertTrue(experimentsMap["second-name"] == true) + assertTrue(experimentsMap["third-name"] == true) + } + + @Test + fun isInExperiment() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + var experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "test.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + var instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + assertTrue(instance.isInExperiment(context, ExperimentDescriptor("first-name"))) + + experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "other.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + instance = Experiments(experimentSource, experimentStorage) + + assertFalse(instance.isInExperiment(context, ExperimentDescriptor("first-name"))) + assertFalse(instance.isInExperiment(context, ExperimentDescriptor("other-name"))) + } + + @Test + fun withExperiment() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + var experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "test.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + var instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + var invocations = 0 + instance.withExperiment(context, ExperimentDescriptor("first-name")) { + invocations++ + assertEquals(experiments[0], it) + } + assertEquals(1, invocations) + + experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "other.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + instance = Experiments(experimentSource, experimentStorage) + + invocations = 0 + instance.withExperiment(context, ExperimentDescriptor("first-name")) { + invocations++ + } + assertEquals(0, invocations) + + invocations = 0 + instance.withExperiment(context, ExperimentDescriptor("other-name")) { + invocations++ + } + assertEquals(0, invocations) + } + + @Test + fun getExperiment() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "test.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + assertEquals(experiments[0], instance.getExperiment(ExperimentDescriptor("first-name"))) + assertNull(instance.getExperiment(ExperimentDescriptor("other-name"))) + } + + @Test + fun setOverride() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "test.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.putBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenReturn(prefsEditor) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + assertTrue(instance.isInExperiment(context, ExperimentDescriptor("first-name"))) + instance.setOverride(context, ExperimentDescriptor("first-name"), false) + verify(prefsEditor).putBoolean("first-name", false) + instance.setOverride(context, ExperimentDescriptor("first-name"), true) + verify(prefsEditor).putBoolean("first-name", true) + + runBlocking(Dispatchers.Default) { + assertTrue(instance.isInExperiment(context, ExperimentDescriptor("first-name"))) + instance.setOverrideNow(context, ExperimentDescriptor("first-name"), false) + verify(prefsEditor, times(2)).putBoolean("first-name", false) + instance.setOverrideNow(context, ExperimentDescriptor("first-name"), true) + verify(prefsEditor, times(2)).putBoolean("first-name", true) + } + } + + @Test + fun clearOverride() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "test.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.remove(ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(prefsEditor.putBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenReturn(prefsEditor) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + assertTrue(instance.isInExperiment(context, ExperimentDescriptor("first-name"))) + instance.setOverride(context, ExperimentDescriptor("first-name"), false) + instance.clearOverride(context, ExperimentDescriptor("first-name")) + verify(prefsEditor).remove("first-name") + + runBlocking(Dispatchers.Default) { + instance.setOverrideNow(context, ExperimentDescriptor("first-name"), false) + instance.clearOverrideNow(context, ExperimentDescriptor("first-name")) + verify(prefsEditor, times(2)).remove("first-name") + } + } + + @Test + fun clearAllOverrides() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + val experiments = listOf( + Experiment("first-id", + name = "first-name", + match = Experiment.Matcher( + appId = "test.appId" + ) + ) + ) + `when`(experimentStorage.retrieve()).thenReturn(ExperimentsSnapshot(experiments, null)) + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() + + val context = mock(Context::class.java) + `when`(context.packageName).thenReturn("test.appId") + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(prefsEditor.clear()).thenReturn(prefsEditor) + `when`(prefsEditor.putBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenReturn(prefsEditor) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(sharedPrefs.getBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenAnswer { invocation -> invocation.arguments[1] as Boolean } + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val packageInfo = mock(PackageInfo::class.java) + packageInfo.versionName = "version.name" + val packageManager = mock(PackageManager::class.java) + `when`(packageManager.getPackageInfo(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(packageInfo) + `when`(context.packageManager).thenReturn(packageManager) + + assertTrue(instance.isInExperiment(context, ExperimentDescriptor("first-name"))) + instance.setOverride(context, ExperimentDescriptor("first-name"), false) + instance.clearAllOverrides(context) + verify(prefsEditor).clear() + + runBlocking(Dispatchers.Default) { + instance.setOverrideNow(context, ExperimentDescriptor("first-name"), false) + instance.clearAllOverridesNow(context) + verify(prefsEditor, times(2)).clear() + } + } + + @Test + fun updateExperimentsException() { + val source = mock(ExperimentSource::class.java) + doAnswer { + throw ExperimentDownloadException("test") + }.`when`(source).getExperiments(any()) + val storage = mock(ExperimentStorage::class.java) + `when`(storage.retrieve()).thenReturn(ExperimentsSnapshot(listOf(), null)) + val instance = Experiments(source, storage) + instance.updateExperiments() + } + + @Test + fun getUserBucket() { + val context = mock(Context::class.java) + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(prefsEditor.putBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenReturn(prefsEditor) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + `when`(sharedPrefs.getString(ArgumentMatchers.anyString(), ArgumentMatchers.isNull())) + .thenReturn("a94b1dab-030e-4b13-be15-cc80c1eda8b3") + val instance = Experiments(experimentSource, experimentStorage) + assertTrue(instance.getUserBucket(context) == 54) + } + + @Test + fun getUserBucketWithOverridenClientId() { + val experimentSource = mock(ExperimentSource::class.java) + val experimentStorage = mock(ExperimentStorage::class.java) + + val instance1 = Experiments(experimentSource, experimentStorage, object : ValuesProvider() { + override fun getClientId(context: Context): String = "c641eacf-c30c-4171-b403-f077724e848a" + }) + + assertEquals(79, instance1.getUserBucket(RuntimeEnvironment.application)) + + val instance2 = Experiments(experimentSource, experimentStorage, object : ValuesProvider() { + override fun getClientId(context: Context): String = "01a15650-9a5d-4383-a7ba-2f047b25c620" + }) + + assertEquals(55, instance2.getUserBucket(RuntimeEnvironment.application)) + } + + @Test + fun evenDistribution() { + val context = mock(Context::class.java) + val sharedPrefs = mock(SharedPreferences::class.java) + val prefsEditor = mock(SharedPreferences.Editor::class.java) + `when`(sharedPrefs.edit()).thenReturn(prefsEditor) + `when`(prefsEditor.putBoolean(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean())).thenReturn(prefsEditor) + `when`(prefsEditor.putString(ArgumentMatchers.anyString(), ArgumentMatchers.anyString())).thenReturn(prefsEditor) + `when`(context.getSharedPreferences(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(sharedPrefs) + + val distribution = (1..1000).map { + val experimentEvaluator = ExperimentEvaluator() + val f = experimentEvaluator::class.functions.find { it.name == "getUserBucket" } + f!!.isAccessible = true + f.call(experimentEvaluator, context) as Int + } + + distribution + .groupingBy { it } + .eachCount() + .forEach { + assertTrue(it.value in 0..25) + } + + distribution + .groupingBy { it / 10 } + .eachCount() + .forEach { + assertTrue(it.value in 50..150) + } + + distribution + .groupingBy { it / 50 } + .eachCount() + .forEach { + assertTrue(it.value in 350..650) + } + } + + @Test + fun loadingCorruptJSON() { + val experimentSource = mock(ExperimentSource::class.java) + + val file = File(RuntimeEnvironment.application.filesDir, "corrupt-experiments.json") + file.writer().use { + it.write("""{"experiment":[""") + } + + val experimentStorage = FlatFileExperimentStorage(file) + + val instance = Experiments(experimentSource, experimentStorage) + instance.loadExperiments() // Should not throw + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/JSONExperimentParserTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/JSONExperimentParserTest.kt new file mode 100644 index 00000000000..2f09fba2fbd --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/JSONExperimentParserTest.kt @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JSONExperimentParserTest { + @Test + fun toJson() { + val experiment = Experiment("sample-id", + "sample-name", + "sample-description", + Experiment.Matcher("es|en", + "sample-appId", + listOf("US"), + "1.0", + "manufacturer", + "device", + "country", + "release_channel"), + Experiment.Bucket(20, 0), + 1526991669) + val jsonObject = JSONExperimentParser().toJson(experiment) + val buckets = jsonObject.getJSONObject("buckets") + assertEquals(0, buckets.getInt("min")) + assertEquals(20, buckets.getInt("max")) + assertEquals("sample-name", jsonObject.getString("name")) + val match = jsonObject.getJSONObject("match") + val regions = match.getJSONArray("regions") + assertEquals(1, regions.length()) + assertEquals("US", regions.get(0)) + assertEquals("sample-appId", match.getString("appId")) + assertEquals("es|en", match.getString("lang")) + assertEquals("1.0", match.getString("version")) + assertEquals("manufacturer", match.getString("manufacturer")) + assertEquals("device", match.getString("device")) + assertEquals("country", match.getString("country")) + assertEquals("release_channel", match.getString("release_channel")) + assertEquals("sample-description", jsonObject.getString("description")) + assertEquals("sample-id", jsonObject.getString("id")) + assertEquals(1526991669, jsonObject.getLong("last_modified")) + } + + @Test + fun toJsonNullValues() { + val experiment = Experiment("id", "name") + val jsonObject = JSONExperimentParser().toJson(experiment) + val buckets = jsonObject.getJSONObject("buckets") + assertEquals(0, buckets.length()) + val match = jsonObject.getJSONObject("match") + assertEquals(0, match.length()) + assertEquals("id", jsonObject.getString("id")) + assertEquals("name", jsonObject.getString("name")) + } + + @Test + fun fromJson() { + val json = """{"buckets":{"min":0,"max":20},"name":"sample-name","match":{"regions":["US"],"appId":"sample-appId","lang":"es|en"},"description":"sample-description","id":"sample-id","last_modified":1526991669}""" + val expectedExperiment = Experiment("sample-id", + "sample-name", + "sample-description", + Experiment.Matcher("es|en", "sample-appId", listOf("US")), + Experiment.Bucket(20, 0), + 1526991669) + assertEquals(expectedExperiment, JSONExperimentParser().fromJson(JSONObject(json))) + } + + @Test + fun fromJsonNonPresentValues() { + val json = """{"id":"id","name":"name"}""" + assertEquals(Experiment("id", "name"), JSONExperimentParser().fromJson(JSONObject(json))) + } + + @Test + fun fromJsonNullValues() { + val json = """{"buckets":null,"name":"sample-name","match":null,"description":null,"id":"sample-id","last_modified":null}""" + assertEquals(Experiment("sample-id", "sample-name"), JSONExperimentParser().fromJson(JSONObject(json))) + val emptyObjects = """{"id":"sample-id","name":"sample-name","buckets":{"min":null,"max":null},"match":{"lang":null,"appId":null,"region":null}}""" + assertEquals(Experiment("sample-id", name = "sample-name", bucket = Experiment.Bucket(), match = Experiment.Matcher()), + JSONExperimentParser().fromJson(JSONObject(emptyObjects))) + } + + @Test + fun payloadFromJson() { + val json = """{"buckets":null,"name":null,"match":null,"description":null,"id":"sample-id","last_modified":null,"values":{"a":"a","b":3,"c":3.5,"d":true,"e":[1,2,3,4]}}""" + val experiment = JSONExperimentParser().fromJson(JSONObject(json)) + assertEquals("a", experiment.payload?.get("a")) + assertEquals(3, experiment.payload?.get("b")) + assertEquals(3.5, experiment.payload?.get("c")) + assertEquals(true, experiment.payload?.get("d")) + assertEquals(listOf(1, 2, 3, 4), experiment.payload?.getIntList("e")) + } + + @Test + fun payloadToJson() { + val payload = ExperimentPayload() + payload.put("a", "a") + payload.put("b", 3) + payload.put("c", 3.5) + payload.put("d", true) + payload.put("e", listOf(1, 2, 3, 4)) + val experiment = Experiment("id", name = "name", payload = payload) + val json = JSONExperimentParser().toJson(experiment) + val payloadJson = json.getJSONObject("values") + assertEquals("a", payloadJson.getString("a")) + assertEquals(3, payloadJson.getInt("b")) + assertEquals(3.5, payloadJson.getDouble("c"), 0.01) + assertEquals(true, payloadJson.getBoolean("d")) + val list = payloadJson.getJSONArray("e") + assertEquals(4, list.length()) + assertEquals(1, list[0]) + assertEquals(2, list[1]) + assertEquals(3, list[2]) + assertEquals(4, list[3]) + val buckets = json.getJSONObject("buckets") + assertEquals(0, buckets.length()) + val match = json.getJSONObject("match") + assertEquals(0, match.length()) + assertEquals("id", json.getString("id")) + } +} diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/ValuesProviderTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ValuesProviderTest.kt new file mode 100644 index 00000000000..e96c145034b --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/ValuesProviderTest.kt @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.robolectric.RobolectricTestRunner +import java.util.Locale +import java.util.MissingResourceException + +@RunWith(RobolectricTestRunner::class) +class ValuesProviderTest { + @Test + fun `get language has three letter code`() { + Locale.setDefault(Locale("en", "US")) + assertEquals("eng", ValuesProvider().getLanguage(mock(Context::class.java))) + } + + @Test + fun `get language doesn't have three letter code`() { + val locale = spy(Locale.getDefault()) + `when`(locale.isO3Language).thenThrow(MissingResourceException("", "", "")) + `when`(locale.language).thenReturn("language") + Locale.setDefault(locale) + assertEquals("language", ValuesProvider().getLanguage(mock(Context::class.java))) + } + + @Test + fun `get country has three letter code`() { + Locale.setDefault(Locale("en", "US")) + assertEquals("USA", ValuesProvider().getCountry(mock(Context::class.java))) + } + + @Test + fun `get country doesn't have three letter code`() { + Locale.setDefault(Locale("cnr", "CS")) + assertEquals("CS", ValuesProvider().getCountry(mock(Context::class.java))) + } +} diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/KintoClientTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/KintoClientTest.kt new file mode 100644 index 00000000000..feb63680a65 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/KintoClientTest.kt @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.source.kinto + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Response +import mozilla.components.service.experiments.ExperimentDownloadException +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import java.io.IOException + +class KintoClientTest { + private val baseUrl = "http://example.test" + private val bucketName = "fretboard" + private val collectionName = "experiments" + + @Test + fun get() { + val httpClient = mock(Client::class.java) + `when`(httpClient.fetch(any())).thenReturn( + Response("", 200, MutableHeaders(), Response.Body("getResult".byteInputStream())) + ) + + val kintoClient = KintoClient(httpClient, baseUrl, bucketName, collectionName) + assertEquals("getResult", kintoClient.get()) + } + + @Test + fun diff() { + val httpClient = mock(Client::class.java) + `when`(httpClient.fetch(any())).thenReturn( + Response("", 200, MutableHeaders(), Response.Body("diffResult".byteInputStream())) + ) + + val kintoClient = spy(KintoClient(httpClient, baseUrl, bucketName, collectionName)) + assertEquals("diffResult", kintoClient.diff(1527179995)) + + verify(kintoClient).fetch(eq("http://example.test/buckets/fretboard/collections/experiments/records?_since=1527179995")) + } + + /** + * On some devices we are seeing an ArrayIndexOutOfBoundsException somewhere inside AOSP/okhttp. + * + * See: + * https://github.com/mozilla-mobile/android-components/issues/964 + */ + @Test(expected = ExperimentDownloadException::class) + fun handlesArrayIndexOutOfBoundsExceptioForFetch() { + val httpClient = mock(Client::class.java) + val kintoClient = KintoClient(httpClient, baseUrl, bucketName, collectionName) + + Mockito.doThrow(ArrayIndexOutOfBoundsException()).`when`(httpClient).fetch(any()) + + kintoClient.fetch("test") + } + + @Test(expected = ExperimentDownloadException::class) + fun handlesIOExceptionForFetch() { + val httpClient = mock(Client::class.java) + val kintoClient = KintoClient(httpClient, baseUrl, bucketName, collectionName) + + Mockito.doThrow(IOException()).`when`(httpClient).fetch(any()) + + kintoClient.fetch("test") + } + + @Test(expected = ExperimentDownloadException::class) + fun handlesFetchErrorResponse() { + val httpClient = mock(Client::class.java) + val kintoClient = KintoClient(httpClient, baseUrl, bucketName, collectionName) + + Mockito.doReturn(Response("", 404, MutableHeaders(), Response.Body.empty())).`when`(httpClient).fetch(any()) + kintoClient.fetch("test") + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/KintoExperimentSourceTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/KintoExperimentSourceTest.kt new file mode 100644 index 00000000000..7e7b457b67e --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/KintoExperimentSourceTest.kt @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.source.kinto + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Response +import mozilla.components.service.experiments.Experiment +import mozilla.components.service.experiments.ExperimentsSnapshot +import mozilla.components.support.test.any +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class KintoExperimentSourceTest { + private val baseUrl = "http://mydomain.test" + private val bucketName = "fretboard" + private val collectionName = "experiments" + + @Test + fun noExperiments() { + val httpClient = mock(Client::class.java) + + val url = "$baseUrl/buckets/$bucketName/collections/$collectionName/records" + `when`(httpClient.fetch(any())) + .thenReturn(Response(url, 200, MutableHeaders(), Response.Body("""{"data":[]}""".byteInputStream()))) + val experimentSource = KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient) + val result = experimentSource.getExperiments(ExperimentsSnapshot(listOf(), null)) + assertEquals(0, result.experiments.size) + assertNull(result.lastModified) + } + + @Test + fun getExperimentsNoDiff() { + val httpClient = mock(Client::class.java) + + val url = "$baseUrl/buckets/$bucketName/collections/$collectionName/records" + `when`(httpClient.fetch(any())).thenReturn( + Response(url, + 200, + MutableHeaders(), + Response.Body("""{"data":[{"name":"first-name","match":{"lang":"eng","appId":"first-appId",regions:[]},"schema":1523549592861,"buckets":{"max":"100","min":"0"},"description":"first-description", "id":"first-id","last_modified":1523549895713}]}""".byteInputStream()))) + + val expectedExperiment = Experiment("first-id", + "first-name", + "first-description", + Experiment.Matcher("eng", "first-appId", listOf()), + Experiment.Bucket(100, 0), + 1523549895713 + ) + + val experimentSource = KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient) + val kintoExperiments = experimentSource.getExperiments(ExperimentsSnapshot(listOf(), null)) + assertEquals(1, kintoExperiments.experiments.size) + assertEquals(expectedExperiment, kintoExperiments.experiments[0]) + assertEquals(1523549895713, kintoExperiments.lastModified) + } + + @Test + fun getExperimentsDiffAdd() { + val httpClient = mock(Client::class.java) + val url = "$baseUrl/buckets/$bucketName/collections/$collectionName/records?_since=1523549890000" + `when`(httpClient.fetch(any())).thenReturn( + Response(url, + 200, + MutableHeaders(), + Response.Body("""{"data":[{"name":"first-name","match":{"lang":"eng","appId":"first-appId",regions:[]},"schema":1523549592861,"buckets":{"max":"100","min":"0"},"description":"first-description", "id":"first-id","last_modified":1523549895713}]}""".byteInputStream()))) + + val kintoExperiment = Experiment("first-id", + "first-name", + "first-description", + Experiment.Matcher("eng", "first-appId", listOf()), + Experiment.Bucket(100, 0), + 1523549895713 + ) + + val storageExperiment = Experiment("id", + "name", + "description", + Experiment.Matcher("eng", "appId", listOf("US")), + Experiment.Bucket(10, 5), + 1523549890000 + ) + + val experimentSource = KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient) + val kintoExperiments = experimentSource.getExperiments(ExperimentsSnapshot(listOf(storageExperiment), 1523549890000)) + assertEquals(2, kintoExperiments.experiments.size) + assertEquals(storageExperiment, kintoExperiments.experiments[0]) + assertEquals(kintoExperiment, kintoExperiments.experiments[1]) + assertEquals(1523549895713, kintoExperiments.lastModified) + } + + @Test + fun getExperimentsDiffDelete() { + val httpClient = mock(Client::class.java) + + val storageExperiment = Experiment("id", + "name", + "description", + Experiment.Matcher("eng", "appId", listOf("US")), + Experiment.Bucket(10, 5), + 1523549890000 + ) + + val secondExperiment = Experiment("id2", + "name2", + "description2", + Experiment.Matcher("eng", "appId", listOf("US")), + Experiment.Bucket(10, 5), + 1523549890000) + + `when`(httpClient.fetch(any())).thenReturn( + Response("$baseUrl/buckets/$bucketName/collections/$collectionName/records?_since=1523549890000", + 200, + MutableHeaders(), + Response.Body("""{"data":[{"deleted":true,"id":"id","last_modified":1523549899999}]}""".byteInputStream()))) + + val experimentSource = KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient) + val kintoExperiments = experimentSource.getExperiments(ExperimentsSnapshot(listOf(storageExperiment, secondExperiment), 1523549890000)) + assertEquals(1, kintoExperiments.experiments.size) + assertEquals(1523549899999, kintoExperiments.lastModified) + + `when`(httpClient.fetch(any())).thenReturn( + Response("$baseUrl/buckets/$bucketName/collections/$collectionName/records?_since=1523549899999", + 200, + MutableHeaders(), + Response.Body("""{"data":[]}""".byteInputStream()))) + + val experimentsResult = experimentSource.getExperiments(ExperimentsSnapshot(kintoExperiments.experiments, 1523549899999)) + assertEquals(1, experimentsResult.experiments.size) + assertEquals(1523549899999, experimentsResult.lastModified) + } + + @Test + fun getExperimentsDiffUpdate() { + val httpClient = mock(Client::class.java) + val url = "$baseUrl/buckets/$bucketName/collections/$collectionName/records?_since=1523549800000" + `when`(httpClient.fetch(any())).thenReturn( + Response(url, + 200, + MutableHeaders(), + Response.Body("""{"data":[{"name":"first-name","match":{"lang":"eng","appId":"first-appId",regions:[]},"schema":1523549592861,"buckets":{"max":"100","min":"0"},"description":"first-description", "id":"first-id","last_modified":1523549895713}]}""".byteInputStream()))) + + val kintoExperiment = Experiment("first-id", + "first-name", + "first-description", + Experiment.Matcher("eng", "first-appId", listOf()), + Experiment.Bucket(100, 0), + 1523549895713 + ) + + val storageExperiment = Experiment("first-id", + "name", + "description", + Experiment.Matcher("es", "appId", listOf("UK")), + Experiment.Bucket(200, 20), + 1523549800000 + ) + + val experimentSource = KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient) + val kintoExperiments = experimentSource.getExperiments(ExperimentsSnapshot(listOf(storageExperiment), 1523549800000)) + assertEquals(1, kintoExperiments.experiments.size) + assertEquals(kintoExperiment, kintoExperiments.experiments[0]) + assertEquals(1523549895713, kintoExperiments.lastModified) + } + + @Test + fun getExperimentsEmptyDiff() { + val httpClient = mock(Client::class.java) + val url = "$baseUrl/buckets/$bucketName/collections/$collectionName/records?_since=1523549895713" + `when`(httpClient.fetch(any())).thenReturn( + Response(url, + 200, + MutableHeaders(), + Response.Body("""{"data":[]}""".byteInputStream()))) + + val storageExperiment = Experiment("first-id", + "first-name", + "first-description", + Experiment.Matcher("eng", "first-appId", listOf()), + Experiment.Bucket(100, 0), + 1523549895713 + ) + + val experimentSource = KintoExperimentSource(baseUrl, bucketName, collectionName, httpClient) + val kintoExperiments = experimentSource.getExperiments(ExperimentsSnapshot(listOf(storageExperiment), 1523549895713)) + assertEquals(1, kintoExperiments.experiments.size) + assertEquals(storageExperiment, kintoExperiments.experiments[0]) + assertEquals(1523549895713, kintoExperiments.lastModified) + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/SignatureVerifierTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/SignatureVerifierTest.kt new file mode 100644 index 00000000000..ebb6608a210 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/source/kinto/SignatureVerifierTest.kt @@ -0,0 +1,639 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.source.kinto + +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.service.experiments.Experiment +import mozilla.components.service.experiments.ExperimentDownloadException +import mozilla.components.service.experiments.JSONExperimentParser +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.Calendar + +@RunWith(RobolectricTestRunner::class) +class SignatureVerifierTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test(expected = ExperimentDownloadException::class) + fun validSignatureOneCertificate() { + val url = server.url("/").url().toString() + val metadataBody = "{\"data\":{\"signature\":{\"x5u\":\"$url\",\"signature\":\"kRhyWZdLyjligYHSFhzhbyzUXBoUwoTPvyt9V0e-E7LKGgUYF2MVfqpA2zfIEDdqrImcMABVGHLUx9Nk614zciRBQ-gyaKA5SL2pPdZvoQXk_LLsPhEBgG4VDnxG4SBL\"}}}" + val certChainBody = "-----BEGIN CERTIFICATE-----\n" + + "MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVUzEc\n" + + "MBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMmTW96aWxsYSBB\n" + + "TU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMTFnJvb3QtY2Et\n" + + "cHJvZHVjdGlvbi1hbW8wHhcNMTUwMzE3MjI1MzU3WhcNMjUwMzE0MjI1MzU3WjB9\n" + + "MQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0G\n" + + "A1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAd\n" + + "BgNVBAMTFnJvb3QtY2EtcHJvZHVjdGlvbi1hbW8wggIgMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDQAwggIIAoICAQC0u2HXXbrwy36+MPeKf5jgoASMfMNz7mJWBecJgvlTf4hH\n" + + "JbLzMPsIUauzI9GEpLfHdZ6wzSyFOb4AM+D1mxAWhuZJ3MDAJOf3B1Rs6QorHrl8\n" + + "qqlNtPGqepnpNJcLo7JsSqqE3NUm72MgqIHRgTRsqUs+7LIPGe7262U+N/T0LPYV\n" + + "Le4rZ2RDHoaZhYY7a9+49mHOI/g2YFB+9yZjE+XdplT2kBgA4P8db7i7I0tIi4b0\n" + + "B0N6y9MhL+CRZJyxdFe2wBykJX14LsheKsM1azHjZO56SKNrW8VAJTLkpRxCmsiT\n" + + "r08fnPyDKmaeZ0BtsugicdipcZpXriIGmsZbI12q5yuwjSELdkDV6Uajo2n+2ws5\n" + + "uXrP342X71WiWhC/dF5dz1LKtjBdmUkxaQMOP/uhtXEKBrZo1ounDRQx1j7+SkQ4\n" + + "BEwjB3SEtr7XDWGOcOIkoJZWPACfBLC3PJCBWjTAyBlud0C5n3Cy9regAAnOIqI1\n" + + "t16GU2laRh7elJ7gPRNgQgwLXeZcFxw6wvyiEcmCjOEQ6PM8UQjthOsKlszMhlKw\n" + + "vjyOGDoztkqSBy/v+Asx7OW2Q7rlVfKarL0mREZdSMfoy3zTgtMVCM0vhNl6zcvf\n" + + "5HNNopoEdg5yuXo2chZ1p1J+q86b0G5yJRMeT2+iOVY2EQ37tHrqUURncCy4uwIB\n" + + "A6OB7TCB6jAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8E\n" + + "DDAKBggrBgEFBQcDAzCBkgYDVR0jBIGKMIGHoYGBpH8wfTELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMR8wHQYDVQQDExZyb290LWNh\n" + + "LXByb2R1Y3Rpb24tYW1vggEBMB0GA1UdDgQWBBSzvOpYdKvhbngqsqucIx6oYyyX\n" + + "tzANBgkqhkiG9w0BAQwFAAOCAgEAaNSRYAaECAePQFyfk12kl8UPLh8hBNidP2H6\n" + + "KT6O0vCVBjxmMrwr8Aqz6NL+TgdPmGRPDDLPDpDJTdWzdj7khAjxqWYhutACTew5\n" + + "eWEaAzyErbKQl+duKvtThhV2p6F6YHJ2vutu4KIciOMKB8dslIqIQr90IX2Usljq\n" + + "8Ttdyf+GhUmazqLtoB0GOuESEqT4unX6X7vSGu1oLV20t7t5eCnMMYD67ZBn0YIU\n" + + "/cm/+pan66hHrja+NeDGF8wabJxdqKItCS3p3GN1zUGuJKrLykxqbOp/21byAGog\n" + + "Z1amhz6NHUcfE6jki7sM7LHjPostU5ZWs3PEfVVgha9fZUhOrIDsyXEpCWVa3481\n" + + "LlAq3GiUMKZ5DVRh9/Nvm4NwrTfB3QkQQJCwfXvO9pwnPKtISYkZUqhEqvXk5nBg\n" + + "QCkDSLDjXTx39naBBGIVIqBtKKuVTla9enngdq692xX/CgO6QJVrwpqdGjebj5P8\n" + + "5fNZPABzTezG3Uls5Vp+4iIWVAEDkK23cUj3c/HhE+Oo7kxfUeu5Y1ZV3qr61+6t\n" + + "ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL\n" + + "26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT\n" + + "wNuvFqc=\n" + + "-----END CERTIFICATE-----" + testSignature(metadataBody, certChainBody, false) + } + + @Test + fun validSignatureCorrect() { + val url = server.url("/").url().toString() + val metadataBody = "{\"data\":{\"signature\":{\"x5u\":\"$url\",\"signature\":\"kRhyWZdLyjligYHSFhzhbyzUXBoUwoTPvyt9V0e-E7LKGgUYF2MVfqpA2zfIEDdqrImcMABVGHLUx9Nk614zciRBQ-gyaKA5SL2pPdZvoQXk_LLsPhEBgG4VDnxG4SBL\"}}}" + val certChainBody = "-----BEGIN CERTIFICATE-----\n" + + "MIIEpTCCBCygAwIBAgIEAQAAKTAKBggqhkjOPQQDAzCBpjELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMSUwIwYDVQQDExxDb250ZW50\n" + + "IFNpZ25pbmcgSW50ZXJtZWRpYXRlMSEwHwYJKoZIhvcNAQkBFhJmb3hzZWNAbW96\n" + + "aWxsYS5jb20wIhgPMjAxODAyMTgwMDAwMDBaGA8yMDE4MDYxODAwMDAwMFowgbEx\n" + + "CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRwwGgYDVQQKExNNb3pp\n" + + "bGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5DbG91ZCBTZXJ2aWNlczExMC8GA1UE\n" + + "AxMoZmVubmVjLWRsYy5jb250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEG\n" + + "CSqGSIb3DQEJARYUc2VjdXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUr\n" + + "gQQAIgNiAATpKfWqAyDsh2ISzBycb8Y7JqLygByKI9vI2WZ2VWaGTYfmB1tQ8PFj\n" + + "vZrtDqZeO8Dhs2KiMrvs/uoziM2zselYQhd0mz5z3dMui6BD6SPKB83K7xn2r2mW\n" + + "plAZxuwnPKujggIYMIICFDAdBgNVHQ4EFgQUTp9+0KSp+vkzbEcXAENBCIyI9eow\n" + + "gaoGA1UdIwSBojCBn4AUiHVymVvwUPJguD2xCZYej3l5nu6hgYGkfzB9MQswCQYD\n" + + "VQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMm\n" + + "TW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMT\n" + + "FnJvb3QtY2EtcHJvZHVjdGlvbi1hbW+CAxAABjAMBgNVHRMBAf8EAjAAMA4GA1Ud\n" + + "DwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBFBgNVHR8EPjA8MDqg\n" + + "OKA2hjRodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmNkbi5tb3ppbGxhLm5ldC9j\n" + + "YS9jcmwucGVtMEMGCWCGSAGG+EIBBAQ2FjRodHRwczovL2NvbnRlbnQtc2lnbmF0\n" + + "dXJlLmNkbi5tb3ppbGxhLm5ldC9jYS9jcmwucGVtME8GCCsGAQUFBwEBBEMwQTA/\n" + + "BggrBgEFBQcwAoYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5jZG4ubW96aWxs\n" + + "YS5uZXQvY2EvY2EucGVtMDMGA1UdEQQsMCqCKGZlbm5lYy1kbGMuY29udGVudC1z\n" + + "aWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMDZwAwZAIwXVe1A+r/yVwR\n" + + "DtecWq2DOOIIbq6jPzz/L6GpAw1KHnVMVBnOyrsFNmPyGQb1H60bAjB0dxyh14JB\n" + + "FCdPO01+y8I7nGRaWqjkmp/GLrdoginSppQYZInYTZagcfy/nIr5fKk=\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIFfjCCA2agAwIBAgIDEAAGMA0GCSqGSIb3DQEBDAUAMH0xCzAJBgNVBAYTAlVT\n" + + "MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3ppbGxh\n" + + "IEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMWcm9vdC1j\n" + + "YS1wcm9kdWN0aW9uLWFtbzAeFw0xNzA1MDQwMDEyMzlaFw0xOTA1MDQwMDEyMzla\n" + + "MIGmMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEv\n" + + "MC0GA1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2Ux\n" + + "JTAjBgNVBAMTHENvbnRlbnQgU2lnbmluZyBJbnRlcm1lZGlhdGUxITAfBgkqhkiG\n" + + "9w0BCQEWEmZveHNlY0Btb3ppbGxhLmNvbTB2MBAGByqGSM49AgEGBSuBBAAiA2IA\n" + + "BMCmt4C33KfMzsyKokc9SXmMSxozksQglhoGAA1KjlgqEOzcmKEkxtvnGWOA9FLo\n" + + "A6U7Wmy+7sqmvmjLboAPQc4G0CEudn5Nfk36uEqeyiyKwKSAT+pZsqS4/maXIC7s\n" + + "DqOCAYkwggGFMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMBYGA1UdJQEB\n" + + "/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSIdXKZW/BQ8mC4PbEJlh6PeXme7jCB\n" + + "qAYDVR0jBIGgMIGdgBSzvOpYdKvhbngqsqucIx6oYyyXt6GBgaR/MH0xCzAJBgNV\n" + + "BAYTAlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZN\n" + + "b3ppbGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMW\n" + + "cm9vdC1jYS1wcm9kdWN0aW9uLWFtb4IBATAzBglghkgBhvhCAQQEJhYkaHR0cDov\n" + + "L2FkZG9ucy5hbGxpem9tLm9yZy9jYS9jcmwucGVtME4GA1UdHgRHMEWgQzAggh4u\n" + + "Y29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwH4IdY29udGVudC1zaWduYXR1\n" + + "cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQEMBQADggIBAKWhLjJB8XmW3VfLvyLF\n" + + "OOUNeNs7Aju+EZl1PMVXf+917LB//FcJKUQLcEo86I6nC3umUNl+kaq4d3yPDpMV\n" + + "4DKLHgGmegRsvAyNFQfd64TTxzyfoyfNWH8uy5vvxPmLvWb+jXCoMNF5FgFWEVon\n" + + "5GDEK8hoHN/DMVe0jveeJhUSuiUpJhMzEf6Vbo0oNgfaRAZKO+VOY617nkTOPnVF\n" + + "LSEcUPIdE8pcd+QP1t/Ysx+mAfkxAbt+5K298s2bIRLTyNUj1eBtTcCbBbFyWsly\n" + + "rSMkJihFAWU2MVKqvJ74YI3uNhFzqJ/AAUAPoet14q+ViYU+8a1lqEWj7y8foF3r\n" + + "m0ZiQpuHULiYCO4y4NR7g5ijj6KsbruLv3e9NyUAIRBHOZEKOA7EiFmWJgqH1aZv\n" + + "/eS7aQ9HMtPKrlbEwUjV0P3K2U2ljs0rNvO8KO9NKQmocXaRpLm+s8PYBGxby92j\n" + + "5eelLq55028BSzhJJc6G+cRT9Hlxf1cg2qtqcVJa8i8wc2upCaGycZIlBSX4gj/4\n" + + "k9faY4qGuGnuEdzAyvIXWMSkb8jiNHQfZrebSr00vShkUEKOLmfFHbkwIaWNK0+2\n" + + "2c3RL4tDnM5u0kvdgWf0B742JskkxqqmEeZVofsOZJLOhXxO9NO/S0hM16/vf/tl\n" + + "Tnsnhv0nxUR0B9wxN7XmWmq4\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVUzEc\n" + + "MBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMmTW96aWxsYSBB\n" + + "TU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMTFnJvb3QtY2Et\n" + + "cHJvZHVjdGlvbi1hbW8wHhcNMTUwMzE3MjI1MzU3WhcNMjUwMzE0MjI1MzU3WjB9\n" + + "MQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0G\n" + + "A1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAd\n" + + "BgNVBAMTFnJvb3QtY2EtcHJvZHVjdGlvbi1hbW8wggIgMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDQAwggIIAoICAQC0u2HXXbrwy36+MPeKf5jgoASMfMNz7mJWBecJgvlTf4hH\n" + + "JbLzMPsIUauzI9GEpLfHdZ6wzSyFOb4AM+D1mxAWhuZJ3MDAJOf3B1Rs6QorHrl8\n" + + "qqlNtPGqepnpNJcLo7JsSqqE3NUm72MgqIHRgTRsqUs+7LIPGe7262U+N/T0LPYV\n" + + "Le4rZ2RDHoaZhYY7a9+49mHOI/g2YFB+9yZjE+XdplT2kBgA4P8db7i7I0tIi4b0\n" + + "B0N6y9MhL+CRZJyxdFe2wBykJX14LsheKsM1azHjZO56SKNrW8VAJTLkpRxCmsiT\n" + + "r08fnPyDKmaeZ0BtsugicdipcZpXriIGmsZbI12q5yuwjSELdkDV6Uajo2n+2ws5\n" + + "uXrP342X71WiWhC/dF5dz1LKtjBdmUkxaQMOP/uhtXEKBrZo1ounDRQx1j7+SkQ4\n" + + "BEwjB3SEtr7XDWGOcOIkoJZWPACfBLC3PJCBWjTAyBlud0C5n3Cy9regAAnOIqI1\n" + + "t16GU2laRh7elJ7gPRNgQgwLXeZcFxw6wvyiEcmCjOEQ6PM8UQjthOsKlszMhlKw\n" + + "vjyOGDoztkqSBy/v+Asx7OW2Q7rlVfKarL0mREZdSMfoy3zTgtMVCM0vhNl6zcvf\n" + + "5HNNopoEdg5yuXo2chZ1p1J+q86b0G5yJRMeT2+iOVY2EQ37tHrqUURncCy4uwIB\n" + + "A6OB7TCB6jAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8E\n" + + "DDAKBggrBgEFBQcDAzCBkgYDVR0jBIGKMIGHoYGBpH8wfTELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMR8wHQYDVQQDExZyb290LWNh\n" + + "LXByb2R1Y3Rpb24tYW1vggEBMB0GA1UdDgQWBBSzvOpYdKvhbngqsqucIx6oYyyX\n" + + "tzANBgkqhkiG9w0BAQwFAAOCAgEAaNSRYAaECAePQFyfk12kl8UPLh8hBNidP2H6\n" + + "KT6O0vCVBjxmMrwr8Aqz6NL+TgdPmGRPDDLPDpDJTdWzdj7khAjxqWYhutACTew5\n" + + "eWEaAzyErbKQl+duKvtThhV2p6F6YHJ2vutu4KIciOMKB8dslIqIQr90IX2Usljq\n" + + "8Ttdyf+GhUmazqLtoB0GOuESEqT4unX6X7vSGu1oLV20t7t5eCnMMYD67ZBn0YIU\n" + + "/cm/+pan66hHrja+NeDGF8wabJxdqKItCS3p3GN1zUGuJKrLykxqbOp/21byAGog\n" + + "Z1amhz6NHUcfE6jki7sM7LHjPostU5ZWs3PEfVVgha9fZUhOrIDsyXEpCWVa3481\n" + + "LlAq3GiUMKZ5DVRh9/Nvm4NwrTfB3QkQQJCwfXvO9pwnPKtISYkZUqhEqvXk5nBg\n" + + "QCkDSLDjXTx39naBBGIVIqBtKKuVTla9enngdq692xX/CgO6QJVrwpqdGjebj5P8\n" + + "5fNZPABzTezG3Uls5Vp+4iIWVAEDkK23cUj3c/HhE+Oo7kxfUeu5Y1ZV3qr61+6t\n" + + "ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL\n" + + "26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT\n" + + "wNuvFqc=\n" + + "-----END CERTIFICATE-----" + testSignature(metadataBody, certChainBody, true) + } + + @Test + fun validSignatureIncorrect() { + val url = server.url("/").url().toString() + val metadataBody = "{\"data\":{\"signature\":{\"x5u\":\"$url\",\"signature\":\"kRyhWZdLyjligYHSFhzhbyzUXBoUwoTPvyt9V0e-E7LKGgUYF2MVfqpA2zfIEDdqrImcMABVGHLUx9Nk614zciRBQ-gyaKA5SL2pPdZvoQXk_LLsPhEBgG4VDnxG4SBL\"}}}" + val certChainBody = "-----BEGIN CERTIFICATE-----\n" + + "MIIEpTCCBCygAwIBAgIEAQAAKTAKBggqhkjOPQQDAzCBpjELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMSUwIwYDVQQDExxDb250ZW50\n" + + "IFNpZ25pbmcgSW50ZXJtZWRpYXRlMSEwHwYJKoZIhvcNAQkBFhJmb3hzZWNAbW96\n" + + "aWxsYS5jb20wIhgPMjAxODAyMTgwMDAwMDBaGA8yMDE4MDYxODAwMDAwMFowgbEx\n" + + "CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRwwGgYDVQQKExNNb3pp\n" + + "bGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5DbG91ZCBTZXJ2aWNlczExMC8GA1UE\n" + + "AxMoZmVubmVjLWRsYy5jb250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEG\n" + + "CSqGSIb3DQEJARYUc2VjdXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUr\n" + + "gQQAIgNiAATpKfWqAyDsh2ISzBycb8Y7JqLygByKI9vI2WZ2VWaGTYfmB1tQ8PFj\n" + + "vZrtDqZeO8Dhs2KiMrvs/uoziM2zselYQhd0mz5z3dMui6BD6SPKB83K7xn2r2mW\n" + + "plAZxuwnPKujggIYMIICFDAdBgNVHQ4EFgQUTp9+0KSp+vkzbEcXAENBCIyI9eow\n" + + "gaoGA1UdIwSBojCBn4AUiHVymVvwUPJguD2xCZYej3l5nu6hgYGkfzB9MQswCQYD\n" + + "VQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMm\n" + + "TW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMT\n" + + "FnJvb3QtY2EtcHJvZHVjdGlvbi1hbW+CAxAABjAMBgNVHRMBAf8EAjAAMA4GA1Ud\n" + + "DwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBFBgNVHR8EPjA8MDqg\n" + + "OKA2hjRodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmNkbi5tb3ppbGxhLm5ldC9j\n" + + "YS9jcmwucGVtMEMGCWCGSAGG+EIBBAQ2FjRodHRwczovL2NvbnRlbnQtc2lnbmF0\n" + + "dXJlLmNkbi5tb3ppbGxhLm5ldC9jYS9jcmwucGVtME8GCCsGAQUFBwEBBEMwQTA/\n" + + "BggrBgEFBQcwAoYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5jZG4ubW96aWxs\n" + + "YS5uZXQvY2EvY2EucGVtMDMGA1UdEQQsMCqCKGZlbm5lYy1kbGMuY29udGVudC1z\n" + + "aWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMDZwAwZAIwXVe1A+r/yVwR\n" + + "DtecWq2DOOIIbq6jPzz/L6GpAw1KHnVMVBnOyrsFNmPyGQb1H60bAjB0dxyh14JB\n" + + "FCdPO01+y8I7nGRaWqjkmp/GLrdoginSppQYZInYTZagcfy/nIr5fKk=\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIFfjCCA2agAwIBAgIDEAAGMA0GCSqGSIb3DQEBDAUAMH0xCzAJBgNVBAYTAlVT\n" + + "MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3ppbGxh\n" + + "IEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMWcm9vdC1j\n" + + "YS1wcm9kdWN0aW9uLWFtbzAeFw0xNzA1MDQwMDEyMzlaFw0xOTA1MDQwMDEyMzla\n" + + "MIGmMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEv\n" + + "MC0GA1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2Ux\n" + + "JTAjBgNVBAMTHENvbnRlbnQgU2lnbmluZyBJbnRlcm1lZGlhdGUxITAfBgkqhkiG\n" + + "9w0BCQEWEmZveHNlY0Btb3ppbGxhLmNvbTB2MBAGByqGSM49AgEGBSuBBAAiA2IA\n" + + "BMCmt4C33KfMzsyKokc9SXmMSxozksQglhoGAA1KjlgqEOzcmKEkxtvnGWOA9FLo\n" + + "A6U7Wmy+7sqmvmjLboAPQc4G0CEudn5Nfk36uEqeyiyKwKSAT+pZsqS4/maXIC7s\n" + + "DqOCAYkwggGFMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMBYGA1UdJQEB\n" + + "/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSIdXKZW/BQ8mC4PbEJlh6PeXme7jCB\n" + + "qAYDVR0jBIGgMIGdgBSzvOpYdKvhbngqsqucIx6oYyyXt6GBgaR/MH0xCzAJBgNV\n" + + "BAYTAlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZN\n" + + "b3ppbGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMW\n" + + "cm9vdC1jYS1wcm9kdWN0aW9uLWFtb4IBATAzBglghkgBhvhCAQQEJhYkaHR0cDov\n" + + "L2FkZG9ucy5hbGxpem9tLm9yZy9jYS9jcmwucGVtME4GA1UdHgRHMEWgQzAggh4u\n" + + "Y29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwH4IdY29udGVudC1zaWduYXR1\n" + + "cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQEMBQADggIBAKWhLjJB8XmW3VfLvyLF\n" + + "OOUNeNs7Aju+EZl1PMVXf+917LB//FcJKUQLcEo86I6nC3umUNl+kaq4d3yPDpMV\n" + + "4DKLHgGmegRsvAyNFQfd64TTxzyfoyfNWH8uy5vvxPmLvWb+jXCoMNF5FgFWEVon\n" + + "5GDEK8hoHN/DMVe0jveeJhUSuiUpJhMzEf6Vbo0oNgfaRAZKO+VOY617nkTOPnVF\n" + + "LSEcUPIdE8pcd+QP1t/Ysx+mAfkxAbt+5K298s2bIRLTyNUj1eBtTcCbBbFyWsly\n" + + "rSMkJihFAWU2MVKqvJ74YI3uNhFzqJ/AAUAPoet14q+ViYU+8a1lqEWj7y8foF3r\n" + + "m0ZiQpuHULiYCO4y4NR7g5ijj6KsbruLv3e9NyUAIRBHOZEKOA7EiFmWJgqH1aZv\n" + + "/eS7aQ9HMtPKrlbEwUjV0P3K2U2ljs0rNvO8KO9NKQmocXaRpLm+s8PYBGxby92j\n" + + "5eelLq55028BSzhJJc6G+cRT9Hlxf1cg2qtqcVJa8i8wc2upCaGycZIlBSX4gj/4\n" + + "k9faY4qGuGnuEdzAyvIXWMSkb8jiNHQfZrebSr00vShkUEKOLmfFHbkwIaWNK0+2\n" + + "2c3RL4tDnM5u0kvdgWf0B742JskkxqqmEeZVofsOZJLOhXxO9NO/S0hM16/vf/tl\n" + + "Tnsnhv0nxUR0B9wxN7XmWmq4\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVUzEc\n" + + "MBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMmTW96aWxsYSBB\n" + + "TU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMTFnJvb3QtY2Et\n" + + "cHJvZHVjdGlvbi1hbW8wHhcNMTUwMzE3MjI1MzU3WhcNMjUwMzE0MjI1MzU3WjB9\n" + + "MQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0G\n" + + "A1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAd\n" + + "BgNVBAMTFnJvb3QtY2EtcHJvZHVjdGlvbi1hbW8wggIgMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDQAwggIIAoICAQC0u2HXXbrwy36+MPeKf5jgoASMfMNz7mJWBecJgvlTf4hH\n" + + "JbLzMPsIUauzI9GEpLfHdZ6wzSyFOb4AM+D1mxAWhuZJ3MDAJOf3B1Rs6QorHrl8\n" + + "qqlNtPGqepnpNJcLo7JsSqqE3NUm72MgqIHRgTRsqUs+7LIPGe7262U+N/T0LPYV\n" + + "Le4rZ2RDHoaZhYY7a9+49mHOI/g2YFB+9yZjE+XdplT2kBgA4P8db7i7I0tIi4b0\n" + + "B0N6y9MhL+CRZJyxdFe2wBykJX14LsheKsM1azHjZO56SKNrW8VAJTLkpRxCmsiT\n" + + "r08fnPyDKmaeZ0BtsugicdipcZpXriIGmsZbI12q5yuwjSELdkDV6Uajo2n+2ws5\n" + + "uXrP342X71WiWhC/dF5dz1LKtjBdmUkxaQMOP/uhtXEKBrZo1ounDRQx1j7+SkQ4\n" + + "BEwjB3SEtr7XDWGOcOIkoJZWPACfBLC3PJCBWjTAyBlud0C5n3Cy9regAAnOIqI1\n" + + "t16GU2laRh7elJ7gPRNgQgwLXeZcFxw6wvyiEcmCjOEQ6PM8UQjthOsKlszMhlKw\n" + + "vjyOGDoztkqSBy/v+Asx7OW2Q7rlVfKarL0mREZdSMfoy3zTgtMVCM0vhNl6zcvf\n" + + "5HNNopoEdg5yuXo2chZ1p1J+q86b0G5yJRMeT2+iOVY2EQ37tHrqUURncCy4uwIB\n" + + "A6OB7TCB6jAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8E\n" + + "DDAKBggrBgEFBQcDAzCBkgYDVR0jBIGKMIGHoYGBpH8wfTELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMR8wHQYDVQQDExZyb290LWNh\n" + + "LXByb2R1Y3Rpb24tYW1vggEBMB0GA1UdDgQWBBSzvOpYdKvhbngqsqucIx6oYyyX\n" + + "tzANBgkqhkiG9w0BAQwFAAOCAgEAaNSRYAaECAePQFyfk12kl8UPLh8hBNidP2H6\n" + + "KT6O0vCVBjxmMrwr8Aqz6NL+TgdPmGRPDDLPDpDJTdWzdj7khAjxqWYhutACTew5\n" + + "eWEaAzyErbKQl+duKvtThhV2p6F6YHJ2vutu4KIciOMKB8dslIqIQr90IX2Usljq\n" + + "8Ttdyf+GhUmazqLtoB0GOuESEqT4unX6X7vSGu1oLV20t7t5eCnMMYD67ZBn0YIU\n" + + "/cm/+pan66hHrja+NeDGF8wabJxdqKItCS3p3GN1zUGuJKrLykxqbOp/21byAGog\n" + + "Z1amhz6NHUcfE6jki7sM7LHjPostU5ZWs3PEfVVgha9fZUhOrIDsyXEpCWVa3481\n" + + "LlAq3GiUMKZ5DVRh9/Nvm4NwrTfB3QkQQJCwfXvO9pwnPKtISYkZUqhEqvXk5nBg\n" + + "QCkDSLDjXTx39naBBGIVIqBtKKuVTla9enngdq692xX/CgO6QJVrwpqdGjebj5P8\n" + + "5fNZPABzTezG3Uls5Vp+4iIWVAEDkK23cUj3c/HhE+Oo7kxfUeu5Y1ZV3qr61+6t\n" + + "ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL\n" + + "26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT\n" + + "wNuvFqc=\n" + + "-----END CERTIFICATE-----" + testSignature(metadataBody, certChainBody, false) + } + + @Test(expected = ExperimentDownloadException::class) + fun validSignatureInvalidChainOrder() { + val url = server.url("/").url().toString() + val metadataBody = "{\"data\":{\"signature\":{\"x5u\":\"$url\",\"signature\":\"kRhyWZdLyjligYHSFhzhbyzUXBoUwoTPvyt9V0e-E7LKGgUYF2MVfqpA2zfIEDdqrImcMABVGHLUx9Nk614zciRBQ-gyaKA5SL2pPdZvoQXk_LLsPhEBgG4VDnxG4SBL\"}}}" + val certChainBody = "-----BEGIN CERTIFICATE-----\n" + + "MIIEpTCCBCygAwIBAgIEAQAAKTAKBggqhkjOPQQDAzCBpjELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMSUwIwYDVQQDExxDb250ZW50\n" + + "IFNpZ25pbmcgSW50ZXJtZWRpYXRlMSEwHwYJKoZIhvcNAQkBFhJmb3hzZWNAbW96\n" + + "aWxsYS5jb20wIhgPMjAxODAyMTgwMDAwMDBaGA8yMDE4MDYxODAwMDAwMFowgbEx\n" + + "CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRwwGgYDVQQKExNNb3pp\n" + + "bGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5DbG91ZCBTZXJ2aWNlczExMC8GA1UE\n" + + "AxMoZmVubmVjLWRsYy5jb250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEG\n" + + "CSqGSIb3DQEJARYUc2VjdXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUr\n" + + "gQQAIgNiAATpKfWqAyDsh2ISzBycb8Y7JqLygByKI9vI2WZ2VWaGTYfmB1tQ8PFj\n" + + "vZrtDqZeO8Dhs2KiMrvs/uoziM2zselYQhd0mz5z3dMui6BD6SPKB83K7xn2r2mW\n" + + "plAZxuwnPKujggIYMIICFDAdBgNVHQ4EFgQUTp9+0KSp+vkzbEcXAENBCIyI9eow\n" + + "gaoGA1UdIwSBojCBn4AUiHVymVvwUPJguD2xCZYej3l5nu6hgYGkfzB9MQswCQYD\n" + + "VQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMm\n" + + "TW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMT\n" + + "FnJvb3QtY2EtcHJvZHVjdGlvbi1hbW+CAxAABjAMBgNVHRMBAf8EAjAAMA4GA1Ud\n" + + "DwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBFBgNVHR8EPjA8MDqg\n" + + "OKA2hjRodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmNkbi5tb3ppbGxhLm5ldC9j\n" + + "YS9jcmwucGVtMEMGCWCGSAGG+EIBBAQ2FjRodHRwczovL2NvbnRlbnQtc2lnbmF0\n" + + "dXJlLmNkbi5tb3ppbGxhLm5ldC9jYS9jcmwucGVtME8GCCsGAQUFBwEBBEMwQTA/\n" + + "BggrBgEFBQcwAoYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5jZG4ubW96aWxs\n" + + "YS5uZXQvY2EvY2EucGVtMDMGA1UdEQQsMCqCKGZlbm5lYy1kbGMuY29udGVudC1z\n" + + "aWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMDZwAwZAIwXVe1A+r/yVwR\n" + + "DtecWq2DOOIIbq6jPzz/L6GpAw1KHnVMVBnOyrsFNmPyGQb1H60bAjB0dxyh14JB\n" + + "FCdPO01+y8I7nGRaWqjkmp/GLrdoginSppQYZInYTZagcfy/nIr5fKk=\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVUzEc\n" + + "MBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMmTW96aWxsYSBB\n" + + "TU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMTFnJvb3QtY2Et\n" + + "cHJvZHVjdGlvbi1hbW8wHhcNMTUwMzE3MjI1MzU3WhcNMjUwMzE0MjI1MzU3WjB9\n" + + "MQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0G\n" + + "A1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAd\n" + + "BgNVBAMTFnJvb3QtY2EtcHJvZHVjdGlvbi1hbW8wggIgMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDQAwggIIAoICAQC0u2HXXbrwy36+MPeKf5jgoASMfMNz7mJWBecJgvlTf4hH\n" + + "JbLzMPsIUauzI9GEpLfHdZ6wzSyFOb4AM+D1mxAWhuZJ3MDAJOf3B1Rs6QorHrl8\n" + + "qqlNtPGqepnpNJcLo7JsSqqE3NUm72MgqIHRgTRsqUs+7LIPGe7262U+N/T0LPYV\n" + + "Le4rZ2RDHoaZhYY7a9+49mHOI/g2YFB+9yZjE+XdplT2kBgA4P8db7i7I0tIi4b0\n" + + "B0N6y9MhL+CRZJyxdFe2wBykJX14LsheKsM1azHjZO56SKNrW8VAJTLkpRxCmsiT\n" + + "r08fnPyDKmaeZ0BtsugicdipcZpXriIGmsZbI12q5yuwjSELdkDV6Uajo2n+2ws5\n" + + "uXrP342X71WiWhC/dF5dz1LKtjBdmUkxaQMOP/uhtXEKBrZo1ounDRQx1j7+SkQ4\n" + + "BEwjB3SEtr7XDWGOcOIkoJZWPACfBLC3PJCBWjTAyBlud0C5n3Cy9regAAnOIqI1\n" + + "t16GU2laRh7elJ7gPRNgQgwLXeZcFxw6wvyiEcmCjOEQ6PM8UQjthOsKlszMhlKw\n" + + "vjyOGDoztkqSBy/v+Asx7OW2Q7rlVfKarL0mREZdSMfoy3zTgtMVCM0vhNl6zcvf\n" + + "5HNNopoEdg5yuXo2chZ1p1J+q86b0G5yJRMeT2+iOVY2EQ37tHrqUURncCy4uwIB\n" + + "A6OB7TCB6jAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8E\n" + + "DDAKBggrBgEFBQcDAzCBkgYDVR0jBIGKMIGHoYGBpH8wfTELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMR8wHQYDVQQDExZyb290LWNh\n" + + "LXByb2R1Y3Rpb24tYW1vggEBMB0GA1UdDgQWBBSzvOpYdKvhbngqsqucIx6oYyyX\n" + + "tzANBgkqhkiG9w0BAQwFAAOCAgEAaNSRYAaECAePQFyfk12kl8UPLh8hBNidP2H6\n" + + "KT6O0vCVBjxmMrwr8Aqz6NL+TgdPmGRPDDLPDpDJTdWzdj7khAjxqWYhutACTew5\n" + + "eWEaAzyErbKQl+duKvtThhV2p6F6YHJ2vutu4KIciOMKB8dslIqIQr90IX2Usljq\n" + + "8Ttdyf+GhUmazqLtoB0GOuESEqT4unX6X7vSGu1oLV20t7t5eCnMMYD67ZBn0YIU\n" + + "/cm/+pan66hHrja+NeDGF8wabJxdqKItCS3p3GN1zUGuJKrLykxqbOp/21byAGog\n" + + "Z1amhz6NHUcfE6jki7sM7LHjPostU5ZWs3PEfVVgha9fZUhOrIDsyXEpCWVa3481\n" + + "LlAq3GiUMKZ5DVRh9/Nvm4NwrTfB3QkQQJCwfXvO9pwnPKtISYkZUqhEqvXk5nBg\n" + + "QCkDSLDjXTx39naBBGIVIqBtKKuVTla9enngdq692xX/CgO6QJVrwpqdGjebj5P8\n" + + "5fNZPABzTezG3Uls5Vp+4iIWVAEDkK23cUj3c/HhE+Oo7kxfUeu5Y1ZV3qr61+6t\n" + + "ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL\n" + + "26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT\n" + + "wNuvFqc=\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIFfjCCA2agAwIBAgIDEAAGMA0GCSqGSIb3DQEBDAUAMH0xCzAJBgNVBAYTAlVT\n" + + "MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3ppbGxh\n" + + "IEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMWcm9vdC1j\n" + + "YS1wcm9kdWN0aW9uLWFtbzAeFw0xNzA1MDQwMDEyMzlaFw0xOTA1MDQwMDEyMzla\n" + + "MIGmMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEv\n" + + "MC0GA1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2Ux\n" + + "JTAjBgNVBAMTHENvbnRlbnQgU2lnbmluZyBJbnRlcm1lZGlhdGUxITAfBgkqhkiG\n" + + "9w0BCQEWEmZveHNlY0Btb3ppbGxhLmNvbTB2MBAGByqGSM49AgEGBSuBBAAiA2IA\n" + + "BMCmt4C33KfMzsyKokc9SXmMSxozksQglhoGAA1KjlgqEOzcmKEkxtvnGWOA9FLo\n" + + "A6U7Wmy+7sqmvmjLboAPQc4G0CEudn5Nfk36uEqeyiyKwKSAT+pZsqS4/maXIC7s\n" + + "DqOCAYkwggGFMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMBYGA1UdJQEB\n" + + "/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSIdXKZW/BQ8mC4PbEJlh6PeXme7jCB\n" + + "qAYDVR0jBIGgMIGdgBSzvOpYdKvhbngqsqucIx6oYyyXt6GBgaR/MH0xCzAJBgNV\n" + + "BAYTAlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZN\n" + + "b3ppbGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMW\n" + + "cm9vdC1jYS1wcm9kdWN0aW9uLWFtb4IBATAzBglghkgBhvhCAQQEJhYkaHR0cDov\n" + + "L2FkZG9ucy5hbGxpem9tLm9yZy9jYS9jcmwucGVtME4GA1UdHgRHMEWgQzAggh4u\n" + + "Y29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwH4IdY29udGVudC1zaWduYXR1\n" + + "cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQEMBQADggIBAKWhLjJB8XmW3VfLvyLF\n" + + "OOUNeNs7Aju+EZl1PMVXf+917LB//FcJKUQLcEo86I6nC3umUNl+kaq4d3yPDpMV\n" + + "4DKLHgGmegRsvAyNFQfd64TTxzyfoyfNWH8uy5vvxPmLvWb+jXCoMNF5FgFWEVon\n" + + "5GDEK8hoHN/DMVe0jveeJhUSuiUpJhMzEf6Vbo0oNgfaRAZKO+VOY617nkTOPnVF\n" + + "LSEcUPIdE8pcd+QP1t/Ysx+mAfkxAbt+5K298s2bIRLTyNUj1eBtTcCbBbFyWsly\n" + + "rSMkJihFAWU2MVKqvJ74YI3uNhFzqJ/AAUAPoet14q+ViYU+8a1lqEWj7y8foF3r\n" + + "m0ZiQpuHULiYCO4y4NR7g5ijj6KsbruLv3e9NyUAIRBHOZEKOA7EiFmWJgqH1aZv\n" + + "/eS7aQ9HMtPKrlbEwUjV0P3K2U2ljs0rNvO8KO9NKQmocXaRpLm+s8PYBGxby92j\n" + + "5eelLq55028BSzhJJc6G+cRT9Hlxf1cg2qtqcVJa8i8wc2upCaGycZIlBSX4gj/4\n" + + "k9faY4qGuGnuEdzAyvIXWMSkb8jiNHQfZrebSr00vShkUEKOLmfFHbkwIaWNK0+2\n" + + "2c3RL4tDnM5u0kvdgWf0B742JskkxqqmEeZVofsOZJLOhXxO9NO/S0hM16/vf/tl\n" + + "Tnsnhv0nxUR0B9wxN7XmWmq4\n" + + "-----END CERTIFICATE-----\n" + testSignature(metadataBody, certChainBody, true) + } + + @Test(expected = ExperimentDownloadException::class) + fun validSignatureExpiredCertificate() { + val url = server.url("/").url().toString() + val metadataBody = "{\"data\":{\"signature\":{\"x5u\":\"$url\",\"signature\":\"kRhyWZdLyjligYHSFhzhbyzUXBoUwoTPvyt9V0e-E7LKGgUYF2MVfqpA2zfIEDdqrImcMABVGHLUx9Nk614zciRBQ-gyaKA5SL2pPdZvoQXk_LLsPhEBgG4VDnxG4SBL\"}}}" + val certChainBody = "-----BEGIN CERTIFICATE-----\n" + + "MIIEpTCCBCygAwIBAgIEAQAAKTAKBggqhkjOPQQDAzCBpjELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMSUwIwYDVQQDExxDb250ZW50\n" + + "IFNpZ25pbmcgSW50ZXJtZWRpYXRlMSEwHwYJKoZIhvcNAQkBFhJmb3hzZWNAbW96\n" + + "aWxsYS5jb20wIhgPMjAxODAyMTgwMDAwMDBaGA8yMDE4MDYxODAwMDAwMFowgbEx\n" + + "CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRwwGgYDVQQKExNNb3pp\n" + + "bGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5DbG91ZCBTZXJ2aWNlczExMC8GA1UE\n" + + "AxMoZmVubmVjLWRsYy5jb250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEG\n" + + "CSqGSIb3DQEJARYUc2VjdXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUr\n" + + "gQQAIgNiAATpKfWqAyDsh2ISzBycb8Y7JqLygByKI9vI2WZ2VWaGTYfmB1tQ8PFj\n" + + "vZrtDqZeO8Dhs2KiMrvs/uoziM2zselYQhd0mz5z3dMui6BD6SPKB83K7xn2r2mW\n" + + "plAZxuwnPKujggIYMIICFDAdBgNVHQ4EFgQUTp9+0KSp+vkzbEcXAENBCIyI9eow\n" + + "gaoGA1UdIwSBojCBn4AUiHVymVvwUPJguD2xCZYej3l5nu6hgYGkfzB9MQswCQYD\n" + + "VQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMm\n" + + "TW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMT\n" + + "FnJvb3QtY2EtcHJvZHVjdGlvbi1hbW+CAxAABjAMBgNVHRMBAf8EAjAAMA4GA1Ud\n" + + "DwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBFBgNVHR8EPjA8MDqg\n" + + "OKA2hjRodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmNkbi5tb3ppbGxhLm5ldC9j\n" + + "YS9jcmwucGVtMEMGCWCGSAGG+EIBBAQ2FjRodHRwczovL2NvbnRlbnQtc2lnbmF0\n" + + "dXJlLmNkbi5tb3ppbGxhLm5ldC9jYS9jcmwucGVtME8GCCsGAQUFBwEBBEMwQTA/\n" + + "BggrBgEFBQcwAoYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5jZG4ubW96aWxs\n" + + "YS5uZXQvY2EvY2EucGVtMDMGA1UdEQQsMCqCKGZlbm5lYy1kbGMuY29udGVudC1z\n" + + "aWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMDZwAwZAIwXVe1A+r/yVwR\n" + + "DtecWq2DOOIIbq6jPzz/L6GpAw1KHnVMVBnOyrsFNmPyGQb1H60bAjB0dxyh14JB\n" + + "FCdPO01+y8I7nGRaWqjkmp/GLrdoginSppQYZInYTZagcfy/nIr5fKk=\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIFfjCCA2agAwIBAgIDEAAGMA0GCSqGSIb3DQEBDAUAMH0xCzAJBgNVBAYTAlVT\n" + + "MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3ppbGxh\n" + + "IEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMWcm9vdC1j\n" + + "YS1wcm9kdWN0aW9uLWFtbzAeFw0xNzA1MDQwMDEyMzlaFw0xOTA1MDQwMDEyMzla\n" + + "MIGmMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEv\n" + + "MC0GA1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2Ux\n" + + "JTAjBgNVBAMTHENvbnRlbnQgU2lnbmluZyBJbnRlcm1lZGlhdGUxITAfBgkqhkiG\n" + + "9w0BCQEWEmZveHNlY0Btb3ppbGxhLmNvbTB2MBAGByqGSM49AgEGBSuBBAAiA2IA\n" + + "BMCmt4C33KfMzsyKokc9SXmMSxozksQglhoGAA1KjlgqEOzcmKEkxtvnGWOA9FLo\n" + + "A6U7Wmy+7sqmvmjLboAPQc4G0CEudn5Nfk36uEqeyiyKwKSAT+pZsqS4/maXIC7s\n" + + "DqOCAYkwggGFMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMBYGA1UdJQEB\n" + + "/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSIdXKZW/BQ8mC4PbEJlh6PeXme7jCB\n" + + "qAYDVR0jBIGgMIGdgBSzvOpYdKvhbngqsqucIx6oYyyXt6GBgaR/MH0xCzAJBgNV\n" + + "BAYTAlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZN\n" + + "b3ppbGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMW\n" + + "cm9vdC1jYS1wcm9kdWN0aW9uLWFtb4IBATAzBglghkgBhvhCAQQEJhYkaHR0cDov\n" + + "L2FkZG9ucy5hbGxpem9tLm9yZy9jYS9jcmwucGVtME4GA1UdHgRHMEWgQzAggh4u\n" + + "Y29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwH4IdY29udGVudC1zaWduYXR1\n" + + "cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQEMBQADggIBAKWhLjJB8XmW3VfLvyLF\n" + + "OOUNeNs7Aju+EZl1PMVXf+917LB//FcJKUQLcEo86I6nC3umUNl+kaq4d3yPDpMV\n" + + "4DKLHgGmegRsvAyNFQfd64TTxzyfoyfNWH8uy5vvxPmLvWb+jXCoMNF5FgFWEVon\n" + + "5GDEK8hoHN/DMVe0jveeJhUSuiUpJhMzEf6Vbo0oNgfaRAZKO+VOY617nkTOPnVF\n" + + "LSEcUPIdE8pcd+QP1t/Ysx+mAfkxAbt+5K298s2bIRLTyNUj1eBtTcCbBbFyWsly\n" + + "rSMkJihFAWU2MVKqvJ74YI3uNhFzqJ/AAUAPoet14q+ViYU+8a1lqEWj7y8foF3r\n" + + "m0ZiQpuHULiYCO4y4NR7g5ijj6KsbruLv3e9NyUAIRBHOZEKOA7EiFmWJgqH1aZv\n" + + "/eS7aQ9HMtPKrlbEwUjV0P3K2U2ljs0rNvO8KO9NKQmocXaRpLm+s8PYBGxby92j\n" + + "5eelLq55028BSzhJJc6G+cRT9Hlxf1cg2qtqcVJa8i8wc2upCaGycZIlBSX4gj/4\n" + + "k9faY4qGuGnuEdzAyvIXWMSkb8jiNHQfZrebSr00vShkUEKOLmfFHbkwIaWNK0+2\n" + + "2c3RL4tDnM5u0kvdgWf0B742JskkxqqmEeZVofsOZJLOhXxO9NO/S0hM16/vf/tl\n" + + "Tnsnhv0nxUR0B9wxN7XmWmq4\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVUzEc\n" + + "MBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMmTW96aWxsYSBB\n" + + "TU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMTFnJvb3QtY2Et\n" + + "cHJvZHVjdGlvbi1hbW8wHhcNMTUwMzE3MjI1MzU3WhcNMjUwMzE0MjI1MzU3WjB9\n" + + "MQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0G\n" + + "A1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAd\n" + + "BgNVBAMTFnJvb3QtY2EtcHJvZHVjdGlvbi1hbW8wggIgMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDQAwggIIAoICAQC0u2HXXbrwy36+MPeKf5jgoASMfMNz7mJWBecJgvlTf4hH\n" + + "JbLzMPsIUauzI9GEpLfHdZ6wzSyFOb4AM+D1mxAWhuZJ3MDAJOf3B1Rs6QorHrl8\n" + + "qqlNtPGqepnpNJcLo7JsSqqE3NUm72MgqIHRgTRsqUs+7LIPGe7262U+N/T0LPYV\n" + + "Le4rZ2RDHoaZhYY7a9+49mHOI/g2YFB+9yZjE+XdplT2kBgA4P8db7i7I0tIi4b0\n" + + "B0N6y9MhL+CRZJyxdFe2wBykJX14LsheKsM1azHjZO56SKNrW8VAJTLkpRxCmsiT\n" + + "r08fnPyDKmaeZ0BtsugicdipcZpXriIGmsZbI12q5yuwjSELdkDV6Uajo2n+2ws5\n" + + "uXrP342X71WiWhC/dF5dz1LKtjBdmUkxaQMOP/uhtXEKBrZo1ounDRQx1j7+SkQ4\n" + + "BEwjB3SEtr7XDWGOcOIkoJZWPACfBLC3PJCBWjTAyBlud0C5n3Cy9regAAnOIqI1\n" + + "t16GU2laRh7elJ7gPRNgQgwLXeZcFxw6wvyiEcmCjOEQ6PM8UQjthOsKlszMhlKw\n" + + "vjyOGDoztkqSBy/v+Asx7OW2Q7rlVfKarL0mREZdSMfoy3zTgtMVCM0vhNl6zcvf\n" + + "5HNNopoEdg5yuXo2chZ1p1J+q86b0G5yJRMeT2+iOVY2EQ37tHrqUURncCy4uwIB\n" + + "A6OB7TCB6jAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8E\n" + + "DDAKBggrBgEFBQcDAzCBkgYDVR0jBIGKMIGHoYGBpH8wfTELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMR8wHQYDVQQDExZyb290LWNh\n" + + "LXByb2R1Y3Rpb24tYW1vggEBMB0GA1UdDgQWBBSzvOpYdKvhbngqsqucIx6oYyyX\n" + + "tzANBgkqhkiG9w0BAQwFAAOCAgEAaNSRYAaECAePQFyfk12kl8UPLh8hBNidP2H6\n" + + "KT6O0vCVBjxmMrwr8Aqz6NL+TgdPmGRPDDLPDpDJTdWzdj7khAjxqWYhutACTew5\n" + + "eWEaAzyErbKQl+duKvtThhV2p6F6YHJ2vutu4KIciOMKB8dslIqIQr90IX2Usljq\n" + + "8Ttdyf+GhUmazqLtoB0GOuESEqT4unX6X7vSGu1oLV20t7t5eCnMMYD67ZBn0YIU\n" + + "/cm/+pan66hHrja+NeDGF8wabJxdqKItCS3p3GN1zUGuJKrLykxqbOp/21byAGog\n" + + "Z1amhz6NHUcfE6jki7sM7LHjPostU5ZWs3PEfVVgha9fZUhOrIDsyXEpCWVa3481\n" + + "LlAq3GiUMKZ5DVRh9/Nvm4NwrTfB3QkQQJCwfXvO9pwnPKtISYkZUqhEqvXk5nBg\n" + + "QCkDSLDjXTx39naBBGIVIqBtKKuVTla9enngdq692xX/CgO6QJVrwpqdGjebj5P8\n" + + "5fNZPABzTezG3Uls5Vp+4iIWVAEDkK23cUj3c/HhE+Oo7kxfUeu5Y1ZV3qr61+6t\n" + + "ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL\n" + + "26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT\n" + + "wNuvFqc=\n" + + "-----END CERTIFICATE-----" + + val calendar = Calendar.getInstance() + calendar.set(Calendar.YEAR, 2028) + calendar.set(Calendar.MONTH, 6) + calendar.set(Calendar.DAY_OF_MONTH, 13) + testSignature(metadataBody, certChainBody, false, calendar.time) + } + + @Test(expected = ExperimentDownloadException::class) + fun validSignatureCertificateNotYetValid() { + val url = server.url("/").url().toString() + val metadataBody = "{\"data\":{\"signature\":{\"x5u\":\"$url\",\"signature\":\"kRhyWZdLyjligYHSFhzhbyzUXBoUwoTPvyt9V0e-E7LKGgUYF2MVfqpA2zfIEDdqrImcMABVGHLUx9Nk614zciRBQ-gyaKA5SL2pPdZvoQXk_LLsPhEBgG4VDnxG4SBL\"}}}" + val certChainBody = "-----BEGIN CERTIFICATE-----\n" + + "MIIEpTCCBCygAwIBAgIEAQAAKTAKBggqhkjOPQQDAzCBpjELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMSUwIwYDVQQDExxDb250ZW50\n" + + "IFNpZ25pbmcgSW50ZXJtZWRpYXRlMSEwHwYJKoZIhvcNAQkBFhJmb3hzZWNAbW96\n" + + "aWxsYS5jb20wIhgPMjAxODAyMTgwMDAwMDBaGA8yMDE4MDYxODAwMDAwMFowgbEx\n" + + "CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRwwGgYDVQQKExNNb3pp\n" + + "bGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5DbG91ZCBTZXJ2aWNlczExMC8GA1UE\n" + + "AxMoZmVubmVjLWRsYy5jb250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEG\n" + + "CSqGSIb3DQEJARYUc2VjdXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUr\n" + + "gQQAIgNiAATpKfWqAyDsh2ISzBycb8Y7JqLygByKI9vI2WZ2VWaGTYfmB1tQ8PFj\n" + + "vZrtDqZeO8Dhs2KiMrvs/uoziM2zselYQhd0mz5z3dMui6BD6SPKB83K7xn2r2mW\n" + + "plAZxuwnPKujggIYMIICFDAdBgNVHQ4EFgQUTp9+0KSp+vkzbEcXAENBCIyI9eow\n" + + "gaoGA1UdIwSBojCBn4AUiHVymVvwUPJguD2xCZYej3l5nu6hgYGkfzB9MQswCQYD\n" + + "VQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMm\n" + + "TW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMT\n" + + "FnJvb3QtY2EtcHJvZHVjdGlvbi1hbW+CAxAABjAMBgNVHRMBAf8EAjAAMA4GA1Ud\n" + + "DwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBFBgNVHR8EPjA8MDqg\n" + + "OKA2hjRodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmNkbi5tb3ppbGxhLm5ldC9j\n" + + "YS9jcmwucGVtMEMGCWCGSAGG+EIBBAQ2FjRodHRwczovL2NvbnRlbnQtc2lnbmF0\n" + + "dXJlLmNkbi5tb3ppbGxhLm5ldC9jYS9jcmwucGVtME8GCCsGAQUFBwEBBEMwQTA/\n" + + "BggrBgEFBQcwAoYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5jZG4ubW96aWxs\n" + + "YS5uZXQvY2EvY2EucGVtMDMGA1UdEQQsMCqCKGZlbm5lYy1kbGMuY29udGVudC1z\n" + + "aWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMDZwAwZAIwXVe1A+r/yVwR\n" + + "DtecWq2DOOIIbq6jPzz/L6GpAw1KHnVMVBnOyrsFNmPyGQb1H60bAjB0dxyh14JB\n" + + "FCdPO01+y8I7nGRaWqjkmp/GLrdoginSppQYZInYTZagcfy/nIr5fKk=\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIFfjCCA2agAwIBAgIDEAAGMA0GCSqGSIb3DQEBDAUAMH0xCzAJBgNVBAYTAlVT\n" + + "MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3ppbGxh\n" + + "IEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMWcm9vdC1j\n" + + "YS1wcm9kdWN0aW9uLWFtbzAeFw0xNzA1MDQwMDEyMzlaFw0xOTA1MDQwMDEyMzla\n" + + "MIGmMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEv\n" + + "MC0GA1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2Ux\n" + + "JTAjBgNVBAMTHENvbnRlbnQgU2lnbmluZyBJbnRlcm1lZGlhdGUxITAfBgkqhkiG\n" + + "9w0BCQEWEmZveHNlY0Btb3ppbGxhLmNvbTB2MBAGByqGSM49AgEGBSuBBAAiA2IA\n" + + "BMCmt4C33KfMzsyKokc9SXmMSxozksQglhoGAA1KjlgqEOzcmKEkxtvnGWOA9FLo\n" + + "A6U7Wmy+7sqmvmjLboAPQc4G0CEudn5Nfk36uEqeyiyKwKSAT+pZsqS4/maXIC7s\n" + + "DqOCAYkwggGFMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMBYGA1UdJQEB\n" + + "/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSIdXKZW/BQ8mC4PbEJlh6PeXme7jCB\n" + + "qAYDVR0jBIGgMIGdgBSzvOpYdKvhbngqsqucIx6oYyyXt6GBgaR/MH0xCzAJBgNV\n" + + "BAYTAlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZN\n" + + "b3ppbGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTEfMB0GA1UEAxMW\n" + + "cm9vdC1jYS1wcm9kdWN0aW9uLWFtb4IBATAzBglghkgBhvhCAQQEJhYkaHR0cDov\n" + + "L2FkZG9ucy5hbGxpem9tLm9yZy9jYS9jcmwucGVtME4GA1UdHgRHMEWgQzAggh4u\n" + + "Y29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwH4IdY29udGVudC1zaWduYXR1\n" + + "cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQEMBQADggIBAKWhLjJB8XmW3VfLvyLF\n" + + "OOUNeNs7Aju+EZl1PMVXf+917LB//FcJKUQLcEo86I6nC3umUNl+kaq4d3yPDpMV\n" + + "4DKLHgGmegRsvAyNFQfd64TTxzyfoyfNWH8uy5vvxPmLvWb+jXCoMNF5FgFWEVon\n" + + "5GDEK8hoHN/DMVe0jveeJhUSuiUpJhMzEf6Vbo0oNgfaRAZKO+VOY617nkTOPnVF\n" + + "LSEcUPIdE8pcd+QP1t/Ysx+mAfkxAbt+5K298s2bIRLTyNUj1eBtTcCbBbFyWsly\n" + + "rSMkJihFAWU2MVKqvJ74YI3uNhFzqJ/AAUAPoet14q+ViYU+8a1lqEWj7y8foF3r\n" + + "m0ZiQpuHULiYCO4y4NR7g5ijj6KsbruLv3e9NyUAIRBHOZEKOA7EiFmWJgqH1aZv\n" + + "/eS7aQ9HMtPKrlbEwUjV0P3K2U2ljs0rNvO8KO9NKQmocXaRpLm+s8PYBGxby92j\n" + + "5eelLq55028BSzhJJc6G+cRT9Hlxf1cg2qtqcVJa8i8wc2upCaGycZIlBSX4gj/4\n" + + "k9faY4qGuGnuEdzAyvIXWMSkb8jiNHQfZrebSr00vShkUEKOLmfFHbkwIaWNK0+2\n" + + "2c3RL4tDnM5u0kvdgWf0B742JskkxqqmEeZVofsOZJLOhXxO9NO/S0hM16/vf/tl\n" + + "Tnsnhv0nxUR0B9wxN7XmWmq4\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVUzEc\n" + + "MBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0GA1UECxMmTW96aWxsYSBB\n" + + "TU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAdBgNVBAMTFnJvb3QtY2Et\n" + + "cHJvZHVjdGlvbi1hbW8wHhcNMTUwMzE3MjI1MzU3WhcNMjUwMzE0MjI1MzU3WjB9\n" + + "MQswCQYDVQQGEwJVUzEcMBoGA1UEChMTTW96aWxsYSBDb3Jwb3JhdGlvbjEvMC0G\n" + + "A1UECxMmTW96aWxsYSBBTU8gUHJvZHVjdGlvbiBTaWduaW5nIFNlcnZpY2UxHzAd\n" + + "BgNVBAMTFnJvb3QtY2EtcHJvZHVjdGlvbi1hbW8wggIgMA0GCSqGSIb3DQEBAQUA\n" + + "A4ICDQAwggIIAoICAQC0u2HXXbrwy36+MPeKf5jgoASMfMNz7mJWBecJgvlTf4hH\n" + + "JbLzMPsIUauzI9GEpLfHdZ6wzSyFOb4AM+D1mxAWhuZJ3MDAJOf3B1Rs6QorHrl8\n" + + "qqlNtPGqepnpNJcLo7JsSqqE3NUm72MgqIHRgTRsqUs+7LIPGe7262U+N/T0LPYV\n" + + "Le4rZ2RDHoaZhYY7a9+49mHOI/g2YFB+9yZjE+XdplT2kBgA4P8db7i7I0tIi4b0\n" + + "B0N6y9MhL+CRZJyxdFe2wBykJX14LsheKsM1azHjZO56SKNrW8VAJTLkpRxCmsiT\n" + + "r08fnPyDKmaeZ0BtsugicdipcZpXriIGmsZbI12q5yuwjSELdkDV6Uajo2n+2ws5\n" + + "uXrP342X71WiWhC/dF5dz1LKtjBdmUkxaQMOP/uhtXEKBrZo1ounDRQx1j7+SkQ4\n" + + "BEwjB3SEtr7XDWGOcOIkoJZWPACfBLC3PJCBWjTAyBlud0C5n3Cy9regAAnOIqI1\n" + + "t16GU2laRh7elJ7gPRNgQgwLXeZcFxw6wvyiEcmCjOEQ6PM8UQjthOsKlszMhlKw\n" + + "vjyOGDoztkqSBy/v+Asx7OW2Q7rlVfKarL0mREZdSMfoy3zTgtMVCM0vhNl6zcvf\n" + + "5HNNopoEdg5yuXo2chZ1p1J+q86b0G5yJRMeT2+iOVY2EQ37tHrqUURncCy4uwIB\n" + + "A6OB7TCB6jAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8E\n" + + "DDAKBggrBgEFBQcDAzCBkgYDVR0jBIGKMIGHoYGBpH8wfTELMAkGA1UEBhMCVVMx\n" + + "HDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAtBgNVBAsTJk1vemlsbGEg\n" + + "QU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMR8wHQYDVQQDExZyb290LWNh\n" + + "LXByb2R1Y3Rpb24tYW1vggEBMB0GA1UdDgQWBBSzvOpYdKvhbngqsqucIx6oYyyX\n" + + "tzANBgkqhkiG9w0BAQwFAAOCAgEAaNSRYAaECAePQFyfk12kl8UPLh8hBNidP2H6\n" + + "KT6O0vCVBjxmMrwr8Aqz6NL+TgdPmGRPDDLPDpDJTdWzdj7khAjxqWYhutACTew5\n" + + "eWEaAzyErbKQl+duKvtThhV2p6F6YHJ2vutu4KIciOMKB8dslIqIQr90IX2Usljq\n" + + "8Ttdyf+GhUmazqLtoB0GOuESEqT4unX6X7vSGu1oLV20t7t5eCnMMYD67ZBn0YIU\n" + + "/cm/+pan66hHrja+NeDGF8wabJxdqKItCS3p3GN1zUGuJKrLykxqbOp/21byAGog\n" + + "Z1amhz6NHUcfE6jki7sM7LHjPostU5ZWs3PEfVVgha9fZUhOrIDsyXEpCWVa3481\n" + + "LlAq3GiUMKZ5DVRh9/Nvm4NwrTfB3QkQQJCwfXvO9pwnPKtISYkZUqhEqvXk5nBg\n" + + "QCkDSLDjXTx39naBBGIVIqBtKKuVTla9enngdq692xX/CgO6QJVrwpqdGjebj5P8\n" + + "5fNZPABzTezG3Uls5Vp+4iIWVAEDkK23cUj3c/HhE+Oo7kxfUeu5Y1ZV3qr61+6t\n" + + "ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL\n" + + "26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT\n" + + "wNuvFqc=\n" + + "-----END CERTIFICATE-----" + + val calendar = Calendar.getInstance() + calendar.set(Calendar.YEAR, 2010) + calendar.set(Calendar.MONTH, 6) + calendar.set(Calendar.DAY_OF_MONTH, 13) + testSignature(metadataBody, certChainBody, false, calendar.time) + } + + private fun testSignature(metadataBody: String, certChainBody: String, expected: Boolean, currentDate: Date = defaultDate()) { + val url = server.url("/").url().toString() + server.setDispatcher(object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + if (request.path.contains("buckets/testbucket/collections/testcollection")) { + return MockResponse().setBody(metadataBody) + } + return MockResponse().setBody(certChainBody) + } + }) + val experimentsJson = "{\"data\":[{\"name\":\"leanplum-start\",\"match\":{\"lang\":\"eng|zho|deu|fra|ita|ind|por|spa|pol|rus\",\"appId\":\"^org.mozilla.firefox_beta\$|^org.mozilla.firefox\$\",\"regions\":[]},\"schema\":1523549592861,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Enable Leanplum SDK - Bug 1351571 \\nExpand English Users to more region - Bug 1411066\\nEnable 50% eng|zho|deu globally for Leanplum. see https://bugzilla.mozilla.org/show_bug.cgi?id=1411066#c8\",\"id\":\"12f8f0dc-6401-402e-9e7d-3aec52576b87\",\"last_modified\":1523549895713},{\"name\":\"custom-tabs\",\"match\":{\"regions\":[]},\"schema\":1510207707840,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Allows apps to open tabs in a customized UI.\",\"id\":\"5e23b482-8800-47be-b6dc-1a3bb6e455d4\",\"last_modified\":1510211043874},{\"name\":\"activity-stream-opt-out\",\"match\":{\"appId\":\"^org.mozilla.fennec.*\$\",\"regions\":[]},\"schema\":1498764179980,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Enable Activity stream by default for users in the \\\"opt out\\\" group.\",\"id\":\"7d504093-67c4-4afb-adf5-5ad23c7c1995\",\"last_modified\":1500969355986},{\"name\":\"full-bookmark-management\",\"match\":{\"appId\":\"^org.mozilla.fennec.*\$\"},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Bug 1232439 - Show full-page edit bookmark dialog\",\"id\":\"9ae1019b-9107-47c5-83f3-afa73360b020\",\"last_modified\":1498690258010},{\"name\":\"top-addons-menu\",\"match\":{},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Show addon menu item in top-level.\",\"id\":\"46894232-177a-4cd1-b620-47c0b8e5e2aa\",\"last_modified\":1498599522440},{\"name\":\"offline-cache\",\"match\":{\"appId\":\"^org.mozilla.fennec|org.mozilla.firefox_beta\"},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"id\":\"5e4277e0-1029-ea14-1b74-5d25d301c5dc\",\"last_modified\":1497643056372},{\"name\":\"process-background-telemetry\",\"match\":{},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Gate flag for controlling if background telemetry processing (sync ping) is enabled or not.\",\"id\":\"e6f9d217-3f43-478f-bff3-7829d7b9eeeb\",\"last_modified\":1496971360625},{\"name\":\"activity-stream-setting\",\"match\":{\"appId\":\"^org.mozilla.fennec.*\$\"},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Show a setting in \\\"experimental features\\\" for enabling/disabling activity stream.\",\"id\":\"7a022463-67fd-4ba3-8b06-a79d0c5e1fdc\",\"last_modified\":1496331790186},{\"name\":\"activity-stream\",\"match\":{\"appId\":\"^org.mozilla.fennec.*\$\"},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"description\":\"Enable/Disable Activity Stream\",\"id\":\"d4fd9cfb-4c8b-4963-b21e-1c2f4bcd61d6\",\"last_modified\":1496331773809},{\"name\":\"download-content-catalog-sync\",\"match\":{\"appId\":\"\"},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"id\":\"4d2fa5c3-18b2-8734-49be-fe58993d2cf6\",\"last_modified\":1485853244635},{\"name\":\"promote-add-to-homescreen\",\"match\":{\"appId\":\"^org.mozilla.fennec.*\$|^org.mozilla.firefox_beta\$\"},\"schema\":1480618438089,\"values\":{\"minimumTotalVisits\":5,\"lastVisitMaximumAgeMs\":600000,\"lastVisitMinimumAgeMs\":30000},\"buckets\":{\"max\":\"100\",\"min\":\"50\"},\"id\":\"1d05fa3e-095f-b29a-d9b6-ab3a578efd0b\",\"last_modified\":1482264639326},{\"name\":\"triple-readerview-bookmark-prompt\",\"match\":{\"appId\":\"^org.mozilla.fennec.*\$|^org.mozilla.firefox_beta\$\"},\"schema\":1480618438089,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"id\":\"02d7caa1-cd9e-6949-084c-18bc9d468b6b\",\"last_modified\":1482262302021},{\"name\":\"compact-tabs\",\"match\":{},\"schema\":1480618438089,\"buckets\":{\"max\":\"50\",\"min\":\"0\"},\"description\":\"Arrange tabs in two columns in portrait mode (tabs tray)\",\"id\":\"14fdc9f3-cf11-4bee-84f6-98495d08c61f\",\"last_modified\":1482242613284},{\"name\":\"hls-video-playback\",\"match\":{},\"schema\":1467794476773,\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"id\":\"d9f9f124-a4d6-47db-a9f4-cf0d00915088\",\"last_modified\":1477907551487},{\"name\":\"bookmark-history-menu\",\"match\":{},\"buckets\":{\"max\":\"100\",\"min\":\"0\"},\"id\":\"9a53ebfa-772d-d2d8-8307-f98943310360\",\"last_modified\":1467794477013},{\"name\":\"content-notifications-12hrs\",\"match\":{},\"buckets\":{\"max\":\"0\",\"min\":\"0\"},\"id\":\"3e4cef10-3a87-3cdd-4562-0062c2a9125b\",\"last_modified\":1467794476938},{\"name\":\"whatsnew-notification\",\"match\":{},\"buckets\":{\"max\":\"0\",\"min\":\"0\"},\"id\":\"d9fd5223-965c-2f0d-a798-b8cbc96f6e09\",\"last_modified\":1467794476893},{\"name\":\"content-notifications-8am\",\"match\":{},\"buckets\":{\"max\":\"0\",\"min\":\"0\"},\"id\":\"1829570e-f582-298b-63b3-3c9d8380be6b\",\"last_modified\":1467794476875},{\"name\":\"content-notifications-5pm\",\"match\":{},\"buckets\":{\"max\":\"0\",\"min\":\"0\"},\"id\":\"c011528e-e03a-7272-6d8b-ef1d4bea4689\",\"last_modified\":1467794476838}],\"last_modified\":\"1523549895713\"}" + val experimentsJSON = JSONObject(experimentsJson) + val experimentsJSONArray = experimentsJSON.getJSONArray("data") + val experiments = mutableListOf() + val parser = JSONExperimentParser() + for (i in 0 until experimentsJSONArray.length()) { + experiments.add(parser.fromJson(experimentsJSONArray[i] as JSONObject)) + } + val client = HttpURLConnectionClient() + val verifier = SignatureVerifier(client, KintoClient(client, url, "testbucket", "testcollection"), currentDate) + assertEquals(expected, verifier.validSignature(experiments, experimentsJSON.getLong("last_modified"))) + server.shutdown() + } + + private fun defaultDate(): Date { + val calendar = Calendar.getInstance() + calendar.set(Calendar.YEAR, 2018) + calendar.set(Calendar.MONTH, 6) + calendar.set(Calendar.DAY_OF_MONTH, 13) + return calendar.time + } +} diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/storage/flatfile/ExperimentsSerializerTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/storage/flatfile/ExperimentsSerializerTest.kt new file mode 100644 index 00000000000..51094244f48 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/storage/flatfile/ExperimentsSerializerTest.kt @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.storage.flatfile + +import mozilla.components.service.experiments.Experiment +import mozilla.components.service.experiments.ExperimentsSnapshot +import org.json.JSONException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +val experimentsJson = """ + { + "experiments": [ + { + "buckets":{ + "min":0, + "max":100 + }, + "name":"first", + "match":{ + "regions": [ + "esp" + ], + "appId":"^org.mozilla.firefox_beta${'$'}", + "lang":"eng|es|deu|fra" + }, + "description":"Description", + "id":"experiment-id", + "last_modified":1523549895713 + }, + { + "buckets":{ + "min":5, + "max":10 + }, + "name":"second", + "match":{ + "regions": [ + "deu" + ], + "appId":"^org.mozilla.firefox${'$'}", + "lang":"es|deu" + }, + "description":"SecondDescription", + "id":"experiment-2-id", + "last_modified":1523549895749 + } + ], + "last_modified": 1523549895749 + } + """ + +@RunWith(RobolectricTestRunner::class) +class ExperimentsSerializerTest { + @Test + fun fromJsonValid() { + val experimentsResult = ExperimentsSerializer().fromJson(experimentsJson) + val experiments = experimentsResult.experiments + assertEquals(2, experiments.size) + assertEquals("first", experiments[0].name) + assertEquals("eng|es|deu|fra", experiments[0].match!!.language) + assertEquals("^org.mozilla.firefox_beta${'$'}", experiments[0].match!!.appId) + assertEquals(1, experiments[0].match!!.regions!!.size) + assertEquals("esp", experiments[0].match!!.regions!!.get(0)) + assertEquals(100, experiments[0].bucket!!.max) + assertEquals(0, experiments[0].bucket!!.min) + assertEquals("Description", experiments[0].description) + assertEquals("experiment-id", experiments[0].id) + assertEquals(1523549895713, experiments[0].lastModified) + assertEquals("second", experiments[1].name) + assertEquals("es|deu", experiments[1].match!!.language) + assertEquals("^org.mozilla.firefox${'$'}", experiments[1].match!!.appId) + assertEquals(1, experiments[1].match!!.regions!!.size) + assertEquals("deu", experiments[1].match!!.regions!!.get(0)) + assertEquals(10, experiments[1].bucket!!.max) + assertEquals(5, experiments[1].bucket!!.min) + assertEquals("SecondDescription", experiments[1].description) + assertEquals("experiment-2-id", experiments[1].id) + assertEquals(1523549895749, experiments[1].lastModified) + assertEquals(1523549895749, experimentsResult.lastModified) + } + + @Test(expected = JSONException::class) + fun fromJsonEmptyString() { + ExperimentsSerializer().fromJson("") + } + + @Test + fun fromJsonEmptyArray() { + val experimentsJson = """ {"experiments":[]} """ + val experimentsResult = ExperimentsSerializer().fromJson(experimentsJson) + assertEquals(0, experimentsResult.experiments.size) + assertNull(experimentsResult.lastModified) + } + + @Test + fun toJsonValid() { + val experiments = listOf( + Experiment("experiment-id", + "first", + "Description", + Experiment.Matcher("eng|es|deu|fra", "^org.mozilla.firefox_beta${'$'}", listOf("esp")), + Experiment.Bucket(100, 0), + 1523549895713), + Experiment("experiment-2-id", + "second", + "SecondDescription", + Experiment.Matcher("es|deu", "^org.mozilla.firefox${'$'}", listOf("deu")), + Experiment.Bucket(10, 5), + 1523549895749) + ) + val json = JSONObject(ExperimentsSerializer().toJson(ExperimentsSnapshot(experiments, 1523549895749))) + assertEquals(2, json.length()) + assertEquals(1523549895749, json.getLong("last_modified")) + val experimentsArray = json.getJSONArray("experiments") + assertEquals(2, experimentsArray.length()) + val firstExperiment = experimentsArray[0] as JSONObject + val firstExperimentBuckets = firstExperiment.getJSONObject("buckets") + assertEquals(0, firstExperimentBuckets.getInt("min")) + assertEquals(100, firstExperimentBuckets.getInt("max")) + assertEquals("first", firstExperiment.getString("name")) + val firstExperimentMatch = firstExperiment.getJSONObject("match") + val firstExperimentRegions = firstExperimentMatch.getJSONArray("regions") + assertEquals(1, firstExperimentRegions.length()) + assertEquals("esp", firstExperimentRegions[0]) + assertEquals("^org.mozilla.firefox_beta${'$'}", firstExperimentMatch.getString("appId")) + assertEquals("eng|es|deu|fra", firstExperimentMatch.getString("lang")) + assertEquals("Description", firstExperiment.getString("description")) + assertEquals("experiment-id", firstExperiment.getString("id")) + assertEquals(1523549895713, firstExperiment.getLong("last_modified")) + + val secondExperiment = experimentsArray[1] as JSONObject + val secondExperimentBuckets = secondExperiment.getJSONObject("buckets") + assertEquals(5, secondExperimentBuckets.getInt("min")) + assertEquals(10, secondExperimentBuckets.getInt("max")) + assertEquals("second", secondExperiment.getString("name")) + val secondExperimentMatch = secondExperiment.getJSONObject("match") + val secondExperimentRegions = secondExperimentMatch.getJSONArray("regions") + assertEquals(1, secondExperimentRegions.length()) + assertEquals("deu", secondExperimentRegions[0]) + assertEquals("^org.mozilla.firefox${'$'}", secondExperimentMatch.getString("appId")) + assertEquals("es|deu", secondExperimentMatch.getString("lang")) + assertEquals("SecondDescription", secondExperiment.getString("description")) + assertEquals("experiment-2-id", secondExperiment.getString("id")) + assertEquals(1523549895749, secondExperiment.getLong("last_modified")) + } + + @Test + fun toJsonEmptyList() { + val experiments = listOf() + val experimentsJson = JSONObject(ExperimentsSerializer().toJson(ExperimentsSnapshot(experiments, null))) + assertEquals(1, experimentsJson.length()) + val experimentsJsonArray = experimentsJson.getJSONArray("experiments") + assertEquals(0, experimentsJsonArray.length()) + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/java/mozilla/components/service/experiments/storage/flatfile/FlatFileExperimentStorageTest.kt b/components/service/experiments/src/test/java/mozilla/components/service/experiments/storage/flatfile/FlatFileExperimentStorageTest.kt new file mode 100644 index 00000000000..eb13cfdd452 --- /dev/null +++ b/components/service/experiments/src/test/java/mozilla/components/service/experiments/storage/flatfile/FlatFileExperimentStorageTest.kt @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.experiments.storage.flatfile + +import mozilla.components.service.experiments.Experiment +import mozilla.components.service.experiments.ExperimentsSnapshot +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths + +@RunWith(RobolectricTestRunner::class) +class FlatFileExperimentStorageTest { + @Test + fun save() { + val experiments = listOf( + Experiment("sample-id", + "sample-name", + "sample-description", + Experiment.Matcher("es|en", "sample-appId", listOf("US")), + Experiment.Bucket(20, 0), + 1526991669) + ) + val file = File(RuntimeEnvironment.application.filesDir, "experiments.json") + assertFalse(file.exists()) + FlatFileExperimentStorage(file).save(ExperimentsSnapshot(experiments, 1526991669)) + assertTrue(file.exists()) + val experimentsJson = JSONObject(String(Files.readAllBytes(Paths.get(file.absolutePath)))) + checkSavedExperimentsJson(experimentsJson) + file.delete() + } + + @Test + fun replacingContent() { + val file = File(RuntimeEnvironment.application.filesDir, "experiments.json") + + FlatFileExperimentStorage(file).save(ExperimentsSnapshot(listOf( + Experiment("sample-id", + "sample-name", + "sample-description", + Experiment.Matcher(), + Experiment.Bucket(20, 0), + 1526991669) + ), 1526991669)) + + FlatFileExperimentStorage(file).save(ExperimentsSnapshot(listOf( + Experiment("sample-id-updated", + "sample-name-updated", + "sample-description-updated", + Experiment.Matcher(), + Experiment.Bucket(100, 10), + 1526991700) + ), 1526991700)) + + val loadedExperiments = FlatFileExperimentStorage(file).retrieve().experiments + + assertEquals(1, loadedExperiments.size) + + val loadedExperiment = loadedExperiments[0] + + assertEquals("sample-id-updated", loadedExperiment.id) + assertEquals("sample-name-updated", loadedExperiment.name) + assertEquals("sample-description-updated", loadedExperiment.description) + assertEquals(10, loadedExperiment.bucket!!.min) + assertEquals(100, loadedExperiment.bucket!!.max) + assertEquals(1526991700L, loadedExperiment.lastModified) + } + + private fun checkSavedExperimentsJson(experimentsJson: JSONObject) { + assertEquals(2, experimentsJson.length()) + val experimentsJsonArray = experimentsJson.getJSONArray("experiments") + assertEquals(1, experimentsJsonArray.length()) + val experimentJson = experimentsJsonArray[0] as JSONObject + assertEquals("sample-name", experimentJson.getString("name")) + val experimentMatch = experimentJson.getJSONObject("match") + assertEquals("es|en", experimentMatch.getString("lang")) + assertEquals("sample-appId", experimentMatch.getString("appId")) + val experimentRegions = experimentMatch.getJSONArray("regions") + assertEquals(1, experimentRegions.length()) + assertEquals("US", experimentRegions[0]) + val experimentBuckets = experimentJson.getJSONObject("buckets") + assertEquals(20, experimentBuckets.getInt("max")) + assertEquals(0, experimentBuckets.getInt("min")) + assertEquals("sample-description", experimentJson.getString("description")) + assertEquals("sample-id", experimentJson.getString("id")) + assertEquals(1526991669L, experimentJson.getLong("last_modified")) + } + + @Test + fun retrieve() { + val file = File(RuntimeEnvironment.application.filesDir, "experiments.json") + file.writer().use { + it.write("""{"experiments":[{"name":"sample-name","match":{"lang":"es|en","appId":"sample-appId","regions":["US"]},"buckets":{"max":20,"min":0},"description":"sample-description","id":"sample-id","last_modified":1526991669}],"last_modified":1526991669}""") + } + val experimentsResult = FlatFileExperimentStorage(file).retrieve() + val experiments = experimentsResult.experiments + file.delete() + assertEquals(1, experiments.size) + assertEquals("sample-id", experiments[0].id) + assertEquals("sample-description", experiments[0].description) + assertEquals("sample-name", experiments[0].name) + assertEquals(listOf("US"), experiments[0].match?.regions) + assertEquals("es|en", experiments[0].match?.language) + assertEquals("sample-appId", experiments[0].match?.appId) + assertEquals(20, experiments[0].bucket?.max) + assertEquals(0, experiments[0].bucket?.min) + assertEquals(1526991669L, experiments[0].lastModified) + assertEquals(1526991669L, experimentsResult.lastModified) + } + + @Test + fun retrieveFileNotFound() { + val file = File(RuntimeEnvironment.application.filesDir, "missingFile.json") + val experimentsResult = FlatFileExperimentStorage(file).retrieve() + assertEquals(0, experimentsResult.experiments.size) + assertNull(experimentsResult.lastModified) + } + + @Test + fun readingCorruptJSON() { + val file = File(RuntimeEnvironment.application.filesDir, "corrupt-experiments.json") + file.writer().use { + it.write("""{"experiment":[""") + } + + val snapshot = FlatFileExperimentStorage(file).retrieve() + + assertNull(snapshot.lastModified) + assertEquals(0, snapshot.experiments.size) + } +} \ No newline at end of file diff --git a/components/service/experiments/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/service/experiments/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..1f0955d450f --- /dev/null +++ b/components/service/experiments/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/docs/changelog.md b/docs/changelog.md index 131860d30b2..5634ba3c872 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,6 +21,9 @@ permalink: /changelog/ * **feature-downloads** * Fixing bug #2265. In some occasions, when trying to download a file, the download failed and the download notification shows "Unsuccessful download". +* **service-experiments** + * A new client-side experiments SDK for running segmenting user populations to run multi-branch experiments on them. This component is going to replace `service-fretboard`. The SDK is currently in development and the component is not ready to be used yet. + # 0.45.0 * [Commits](https://github.com/mozilla-mobile/android-components/compare/v0.44.0...v0.45.0)