From f23ebdc55983a83d81511bf06428a88000d4db4c Mon Sep 17 00:00:00 2001 From: Matt Creaser Date: Tue, 23 Jan 2024 11:46:02 -0400 Subject: [PATCH 1/4] Add fallback logic for corrupt keys to EncryptedKeyValueRepository --- .../core/store/EncryptedKeyValueRepository.kt | 180 ++++++++++--- .../store/EncryptedKeyValueRepositoryTest.kt | 253 +++++++++++++++--- 2 files changed, 355 insertions(+), 78 deletions(-) diff --git a/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt b/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt index c2010e5df9..b1dd48e2b3 100644 --- a/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt +++ b/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt @@ -17,57 +17,134 @@ package com.amplifyframework.core.store import android.content.Context import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import androidx.annotation.VisibleForTesting import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV import androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM import androidx.security.crypto.MasterKeys +import com.amplifyframework.core.Amplify import java.io.File +import java.security.KeyStore import java.util.UUID -class EncryptedKeyValueRepository( +class EncryptedKeyValueRepository @VisibleForTesting constructor( private val context: Context, - private val sharedPreferencesName: String + private val sharedPreferencesName: String, + private val defaultMasterKeySpec: KeyGenParameterSpec, + private val amplifyMasterKeySpec: KeyGenParameterSpec, + private val fileFactory: (dir: File, fileName: String) -> File ) : KeyValueRepository { - @VisibleForTesting - internal val sharedPreferences: SharedPreferences by lazy { + constructor(context: Context, sharedPreferencesName: String) : this( + context = context, + sharedPreferencesName = sharedPreferencesName, + defaultMasterKeySpec = getDefaultMasterKeySpec(), + amplifyMasterKeySpec = getAmplifyMasterKeySpec(), + fileFactory = { dir, fileName -> File(dir, fileName) } + ) + + private val sharedPreferences by lazy { getOrCreateSharedPreferences() } + + override fun put(dataKey: String, value: String?) = edit { putString(dataKey, value) } + override fun get(dataKey: String): String? = sharedPreferences.getString(dataKey, null) + override fun remove(dataKey: String) = edit { remove(dataKey) } + override fun removeAll() = edit { clear() } + + private inline fun edit(crossinline block: SharedPreferences.Editor.() -> Unit) = with(sharedPreferences.edit()) { + block() + apply() + } + + private fun getOrCreateSharedPreferences(): SharedPreferences { + val identifier = getInstallationIdentifier() + return if (identifier.startsWith(amplifyIdentifierPrefix)) { + // This repository was encrypted with the amplify master key + openKeystoreWithAmplifyMasterKey(identifier) + } else { + // This repository was encrypted with the default master key + openKeystoreWithDefaultMasterKey(identifier) + } + } + + private fun openKeystoreWithAmplifyMasterKey(identifier: String): SharedPreferences { + var amplifyMasterKey = getMasterKeyOrNull(amplifyMasterKeySpec) + if (amplifyMasterKey == null) { + logger.warn("Unable to retrieve Amplify master key. Deleting invalid master key and creating new one") + deleteMasterKey(amplifyMasterKeySpec) + amplifyMasterKey = getMasterKeyOrThrow(amplifyMasterKeySpec) + } + + val fileName = getSharedPrefsFileName(identifier) + + // Return the shared preferences if we can + getSharedPreferencesOrNull(fileName, amplifyMasterKey)?.let { return it } + + logger.warn("Cannot retrieve preferences encrypted with amplify master key. Deleting and recreating.") + deleteSharedPreferences(fileName) + return getSharedPreferencesOrThrow(fileName, amplifyMasterKey) + } + + private fun openKeystoreWithDefaultMasterKey(identifier: String): SharedPreferences { + // Try to open the encrypted preferences using the default master key + getMasterKeyOrNull(defaultMasterKeySpec)?.let { defaultMasterKey -> + val fileName = getSharedPrefsFileName(identifier) + getSharedPreferencesOrNull(fileName, defaultMasterKey)?.let { return it } + } + + logger.warn("Cannot retrieve preferences encrypted with default master key. Deleting and recreating.") + // Delete the existing shared preferences file + deleteSharedPreferences(getSharedPrefsFileName(identifier)) + // Create a new identifier with the amplify prefix + val newIdentifier = createInstallationIdentifier(getInstallationFile()) + // Use the amplify master key to create the new shared preferences + return openKeystoreWithAmplifyMasterKey(newIdentifier) + } + + private fun getSharedPreferencesOrNull(fileName: String, key: String) = try { + getSharedPreferencesOrThrow(fileName = fileName, key = key) + } catch (e: Exception) { null } + + private fun getSharedPreferencesOrThrow(fileName: String, key: String): SharedPreferences = EncryptedSharedPreferences.create( - "$sharedPreferencesName.${getInstallationIdentifier(context, sharedPreferencesName)}", - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), + fileName, + key, context, AES256_SIV, AES256_GCM ) - } - @VisibleForTesting - internal val editor: SharedPreferences.Editor by lazy { - sharedPreferences.edit() + private fun deleteSharedPreferences(fileName: String) = context.deleteSharedPreferences(fileName) + + private fun deleteMasterKey(spec: KeyGenParameterSpec) = KeyStore.getInstance("AndroidKeyStore").run { + load(null) + deleteEntry(spec.keystoreAlias) } - override fun put(dataKey: String, value: String?) { - with(editor) { - putString(dataKey, value) - apply() + private fun getMasterKeyOrNull(spec: KeyGenParameterSpec): String? { + // Getting the Master Key should succeed, but keystore bugs in some OEM implementations mean that + // the master key occasionally becomes corrupted on some devices. We make multiple attempts to ensure that + // the error is not transient. + repeat(3) { attempt -> + try { + return getMasterKeyOrThrow(spec) + } catch (e: Exception) { + logger.warn("Unable to retrieve master key, attempt ${attempt + 1} / 3", e) + } } + return null } - override fun get(dataKey: String): String? = sharedPreferences.getString(dataKey, null) + private fun getMasterKeyOrThrow(spec: KeyGenParameterSpec) = MasterKeys.getOrCreate(spec) - override fun remove(dataKey: String) { - with(editor) { - remove(dataKey) - apply() - } - } + private fun getSharedPrefsFileName(installationIdentifier: String) = + "$sharedPreferencesName.$installationIdentifier" - override fun removeAll() { - with(editor) { - clear() - apply() - } - } + private fun getInstallationFile() = fileFactory( + context.noBackupFilesDir, + "$sharedPreferencesName.installationIdentifier" + ) /** * EncryptedSharedPreferences may have been backed up by the application, but will be unreadable due to the @@ -75,35 +152,56 @@ class EncryptedKeyValueRepository( * with a UUID created in the noBackupFilesDir */ @Synchronized - private fun getInstallationIdentifier(context: Context, keyValueRepoID: String): String { - val identifierFile = File(context.noBackupFilesDir, "$keyValueRepoID.installationIdentifier") + private fun getInstallationIdentifier(): String { + val identifierFile = getInstallationFile() val previousIdentifier = getExistingInstallationIdentifier(identifierFile) - return previousIdentifier ?: createInstallationIdentifier(identifierFile) } /** * Gets the existing installation identifier (if exists) */ - private fun getExistingInstallationIdentifier(identifierFile: File): String? { - return if (identifierFile.exists()) { - val identifier = identifierFile.readText() - identifier.ifBlank { null } - } else { - null - } + private fun getExistingInstallationIdentifier(identifierFile: File) = if (identifierFile.exists()) { + identifierFile.readText().ifBlank { null } + } else { + null } /** * Creates a new installation identifier for the install */ - private fun createInstallationIdentifier(identifierFile: File): String { - val newIdentifier = UUID.randomUUID().toString() + private fun createInstallationIdentifier(identifierFile: File) = + "$amplifyIdentifierPrefix${UUID.randomUUID()}".also { + writeInstallationIdentifier(identifierFile, it) + } + + /** + * Writes installation identifier to disk + */ + private fun writeInstallationIdentifier(identifierFile: File, identifier: String) { try { - identifierFile.writeText(newIdentifier) + identifierFile.writeText(identifier) } catch (e: Exception) { // Failed to write identifier to file, session will be forced to be in memory } - return newIdentifier + } + + companion object { + private val logger = Amplify.Logging.forNamespace(EncryptedKeyValueRepository::class.simpleName!!) + private fun getDefaultMasterKeySpec() = MasterKeys.AES256_GCM_SPEC + + // We create our own KeyGenParameterSpec that is exactly like MasterKeys.AES256_GCM_SPEC except with a different + // alias. This allows us to safely delete this key should it become corrupted without potentially impacting any + // other part of the customer's application. + private fun getAmplifyMasterKeySpec() = KeyGenParameterSpec.Builder( + "amplify_master_key", + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + + // This prefix is used to identify repositories encrypted with the amplifyMasterKey instead of the androidMasterKey + @VisibleForTesting const val amplifyIdentifierPrefix = "__amplify__" } } diff --git a/core/src/test/java/com/amplifyframework/core/store/EncryptedKeyValueRepositoryTest.kt b/core/src/test/java/com/amplifyframework/core/store/EncryptedKeyValueRepositoryTest.kt index c9886a1f7d..6a1a19e3b7 100644 --- a/core/src/test/java/com/amplifyframework/core/store/EncryptedKeyValueRepositoryTest.kt +++ b/core/src/test/java/com/amplifyframework/core/store/EncryptedKeyValueRepositoryTest.kt @@ -17,62 +17,241 @@ package com.amplifyframework.core.store import android.content.Context import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV +import androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM +import androidx.security.crypto.MasterKeys +import com.amplifyframework.core.store.EncryptedKeyValueRepository.Companion.amplifyIdentifierPrefix +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import java.io.File +import java.security.GeneralSecurityException +import java.security.KeyStore +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito -import org.mockito.Mockito.times -import org.mockito.junit.MockitoJUnitRunner +import org.junit.rules.TemporaryFolder -@RunWith(MockitoJUnitRunner::class) class EncryptedKeyValueRepositoryTest { - // Testing using partial mock as the sut is basically a wrapper - // of EncryptedSharedPreferences which is created statically, making it difficult to mock or stub - @Mock - internal lateinit var repository: EncryptedKeyValueRepository + // Use a temporary folder for writing the installation identifier + @get:Rule + val folder = TemporaryFolder() - @Mock - lateinit var context: Context - - @Mock - lateinit var mockPrefs: SharedPreferences - - @Mock - lateinit var mockPrefsEditor: SharedPreferences.Editor + private val context = mockk(relaxed = true) + private val editor = mockk(relaxed = true) + private val sharedPreferences = mockk { + every { edit() } returns editor + } + private val keystore = mockk(relaxed = true) - companion object { - private const val TEST_KEY = "test Data" - private const val TEST_VAL = "test Val" + private val defaultMasterKeySpec = mockk { + every { keystoreAlias } returns "default_master_key" + } + private val amplifyMasterKeySpec = mockk { + every { keystoreAlias } returns "amplify_master_key" } @Before fun setup() { - Mockito.`when`(repository.sharedPreferences).thenReturn(mockPrefs) - Mockito.`when`(repository.editor).thenReturn(mockPrefsEditor) + mockkStatic(EncryptedSharedPreferences::class) + mockkStatic(KeyStore::class) + mockkStatic(MasterKeys::class) + every { KeyStore.getInstance("AndroidKeyStore") } returns keystore + + every { MasterKeys.getOrCreate(defaultMasterKeySpec) } returns "masterKey" + every { MasterKeys.getOrCreate(amplifyMasterKeySpec) } returns "amplifyKey" + + folder.create() + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `gets a value from a preferences`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + + setupSharedPreferences("abcdef") + every { sharedPreferences.getString("foo", null) } returns "bar" + + val repository = createRepository(installationFile) + val result = repository.get("foo") + assertEquals("bar", result) + } + + @Test + fun `puts a value into preferences`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + + setupSharedPreferences("abcdef") + + val repository = createRepository(installationFile) + repository.put("foo", "bar") + + verify { + editor.putString("foo", "bar") + editor.apply() + } + } + + @Test + fun `deletes a value from preferences`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + + setupSharedPreferences("abcdef") + + val repository = createRepository(installationFile) + repository.remove("foo") + + verify { + editor.remove("foo") + editor.apply() + } + } + + @Test + fun `removes all values from preferences`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + + setupSharedPreferences("abcdef") + + val repository = createRepository(installationFile) + repository.removeAll() + + verify { + editor.clear() + editor.apply() + } } @Test - fun testPut() { - Mockito.`when`(repository.put(Mockito.anyString(), Mockito.anyString())).thenCallRealMethod() - repository.put(TEST_KEY, TEST_VAL) - Mockito.verify(mockPrefsEditor, times(1)).putString(TEST_KEY, TEST_VAL) - Mockito.verify(mockPrefsEditor, times(1)).apply() + fun `gets a value from a keystore created with amplify master key`() { + val installationFile = folder.newFile() + installationFile.writeText(amplifyIdentifier("abcdef")) + + setupSharedPreferences(amplifyIdentifier("abcdef")) + every { sharedPreferences.getString("foo", null) } returns "bar" + + val repository = createRepository(installationFile) + val result = repository.get("foo") + assertEquals("bar", result) } @Test - fun testGet() { - Mockito.`when`(repository.get(Mockito.anyString())).thenCallRealMethod() - repository.get(TEST_KEY) - Mockito.verify(mockPrefs, times(1)).getString(TEST_KEY, null) + fun `uses the amplify master key when creating new repositories`() { } @Test - fun testRemove() { - Mockito.`when`(repository.remove(Mockito.anyString())).thenCallRealMethod() - repository.remove(TEST_KEY) - Mockito.verify(mockPrefsEditor, times(1)).remove(TEST_KEY) - Mockito.verify(mockPrefsEditor, times(1)).apply() + fun `recreates the repository with the amplify key if the default master key is corrupted`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + setupSharedPreferences() + + every { MasterKeys.getOrCreate(defaultMasterKeySpec) } throws GeneralSecurityException("error") + + val repository = createRepository(installationFile) + repository.put("foo", "bar") + + // Verify encrypted preferences are using the amplify key + verify { + EncryptedSharedPreferences.create( + match { it.startsWith("test.$amplifyIdentifierPrefix") }, + "amplifyKey", + any(), + any(), + any() + ) + } + } + + @Test + fun `updates the installation identifier if the default master key is corrupted`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + setupSharedPreferences() + + every { MasterKeys.getOrCreate(defaultMasterKeySpec) } throws GeneralSecurityException("error") + + val repository = createRepository(installationFile) + repository.put("foo", "bar") + + // As a side effect the installation identifier should have been updated to the amplify-specific version + val identifier = installationFile.readText() + assertTrue(identifier.startsWith(amplifyIdentifierPrefix)) + } + + @Test + fun `deletes the shared preferences if the default master key is corrupted`() { + val installationFile = folder.newFile() + installationFile.writeText("abcdef") + setupSharedPreferences() + + every { MasterKeys.getOrCreate(defaultMasterKeySpec) } throws GeneralSecurityException("error") + + val repository = createRepository(installationFile) + repository.put("foo", "bar") + + verify { + context.deleteSharedPreferences("test.abcdef") + } + } + + @Test + fun `deletes the amplify master key if it's corrupted`() { + val installationFile = folder.newFile() + installationFile.writeText(amplifyIdentifier("abcdef")) + setupSharedPreferences() + + every { MasterKeys.getOrCreate(amplifyMasterKeySpec) }.throws(GeneralSecurityException("error1")) + .andThenThrows(GeneralSecurityException("error2")) + .andThenThrows(GeneralSecurityException("error3")) + .andThen("amplifyKey2") + + val repository = createRepository(installationFile) + repository.put("foo", "bar") + + verify { + keystore.deleteEntry("amplify_master_key") + } + } + + private fun setupSharedPreferences(identifier: String? = null) { + every { + EncryptedSharedPreferences.create( + if (identifier == null) any() else "test.$identifier", + when { + identifier == null -> any() + identifier.startsWith(amplifyIdentifierPrefix) -> "amplifyKey" + else -> "masterKey" + }, + context, + AES256_SIV, + AES256_GCM + ) + } returns sharedPreferences + } + private fun amplifyIdentifier(identifier: String) = "$amplifyIdentifierPrefix$identifier" + + private fun createRepository(installationFile: File = folder.newFile()): EncryptedKeyValueRepository { + return EncryptedKeyValueRepository( + context, + "test", + defaultMasterKeySpec, + amplifyMasterKeySpec + ) { _, _ -> installationFile } } } From 0399c28a6a1420488f47e716ddca12647d2ab1e3 Mon Sep 17 00:00:00 2001 From: Matt Creaser Date: Tue, 23 Jan 2024 12:41:35 -0400 Subject: [PATCH 2/4] fix comment --- .../core/store/EncryptedKeyValueRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt b/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt index b1dd48e2b3..075a17415b 100644 --- a/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt +++ b/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt @@ -188,6 +188,7 @@ class EncryptedKeyValueRepository @VisibleForTesting constructor( companion object { private val logger = Amplify.Logging.forNamespace(EncryptedKeyValueRepository::class.simpleName!!) + private fun getDefaultMasterKeySpec() = MasterKeys.AES256_GCM_SPEC // We create our own KeyGenParameterSpec that is exactly like MasterKeys.AES256_GCM_SPEC except with a different @@ -201,7 +202,8 @@ class EncryptedKeyValueRepository @VisibleForTesting constructor( .setKeySize(256) .build() - // This prefix is used to identify repositories encrypted with the amplifyMasterKey instead of the androidMasterKey + // This prefix is used to identify repositories encrypted with the amplifyMasterKey instead of the + // defaultMasterKey @VisibleForTesting const val amplifyIdentifierPrefix = "__amplify__" } } From 508746abb6500643617f48574fde42f9cbf08eeb Mon Sep 17 00:00:00 2001 From: Matt Creaser Date: Tue, 23 Jan 2024 13:15:02 -0400 Subject: [PATCH 3/4] Review feedback --- .../core/store/EncryptedKeyValueRepository.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt b/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt index 075a17415b..ab94d8f3b0 100644 --- a/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt +++ b/core/src/main/java/com/amplifyframework/core/store/EncryptedKeyValueRepository.kt @@ -72,7 +72,7 @@ class EncryptedKeyValueRepository @VisibleForTesting constructor( var amplifyMasterKey = getMasterKeyOrNull(amplifyMasterKeySpec) if (amplifyMasterKey == null) { logger.warn("Unable to retrieve Amplify master key. Deleting invalid master key and creating new one") - deleteMasterKey(amplifyMasterKeySpec) + deleteAmplifyMasterKey() amplifyMasterKey = getMasterKeyOrThrow(amplifyMasterKeySpec) } @@ -117,9 +117,9 @@ class EncryptedKeyValueRepository @VisibleForTesting constructor( private fun deleteSharedPreferences(fileName: String) = context.deleteSharedPreferences(fileName) - private fun deleteMasterKey(spec: KeyGenParameterSpec) = KeyStore.getInstance("AndroidKeyStore").run { + private fun deleteAmplifyMasterKey() = KeyStore.getInstance("AndroidKeyStore").run { load(null) - deleteEntry(spec.keystoreAlias) + deleteEntry(amplifyMasterKeySpec.keystoreAlias) } private fun getMasterKeyOrNull(spec: KeyGenParameterSpec): String? { @@ -186,7 +186,7 @@ class EncryptedKeyValueRepository @VisibleForTesting constructor( } } - companion object { + internal companion object { private val logger = Amplify.Logging.forNamespace(EncryptedKeyValueRepository::class.simpleName!!) private fun getDefaultMasterKeySpec() = MasterKeys.AES256_GCM_SPEC @@ -204,6 +204,6 @@ class EncryptedKeyValueRepository @VisibleForTesting constructor( // This prefix is used to identify repositories encrypted with the amplifyMasterKey instead of the // defaultMasterKey - @VisibleForTesting const val amplifyIdentifierPrefix = "__amplify__" + @VisibleForTesting internal const val amplifyIdentifierPrefix = "__amplify__" } } From 5f18f0db18ec1094a9fb47c18c4f13a095b4ffdf Mon Sep 17 00:00:00 2001 From: tjroach Date: Tue, 6 Feb 2024 15:50:57 -0500 Subject: [PATCH 4/4] Fix test --- aws-auth-plugins-core/build.gradle.kts | 1 + .../auth/plugins/core/AWSCognitoIdentityPoolOperationsTest.kt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/aws-auth-plugins-core/build.gradle.kts b/aws-auth-plugins-core/build.gradle.kts index 3647004ae6..f96e2d01bc 100644 --- a/aws-auth-plugins-core/build.gradle.kts +++ b/aws-auth-plugins-core/build.gradle.kts @@ -44,4 +44,5 @@ dependencies { testImplementation(libs.test.mockk) testImplementation(libs.test.kotlin.junit) testImplementation(libs.test.kotlin.coroutines) + testImplementation(libs.test.robolectric) } diff --git a/aws-auth-plugins-core/src/test/java/com/amplifyframework/auth/plugins/core/AWSCognitoIdentityPoolOperationsTest.kt b/aws-auth-plugins-core/src/test/java/com/amplifyframework/auth/plugins/core/AWSCognitoIdentityPoolOperationsTest.kt index ff7ea0303d..ff9e8d34a8 100644 --- a/aws-auth-plugins-core/src/test/java/com/amplifyframework/auth/plugins/core/AWSCognitoIdentityPoolOperationsTest.kt +++ b/aws-auth-plugins-core/src/test/java/com/amplifyframework/auth/plugins/core/AWSCognitoIdentityPoolOperationsTest.kt @@ -38,7 +38,10 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class AWSCognitoIdentityPoolOperationsTest { private val config = AWSCognitoIdentityPoolConfiguration("poolId") private val KEY_LOGINS_PROVIDER = "amplify.${config.poolId}.session.loginsProvider"