diff --git a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/app/MobileSyncSDKManager.java b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/app/MobileSyncSDKManager.java index 82079c54a8..1d368cc0c2 100644 --- a/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/app/MobileSyncSDKManager.java +++ b/libs/MobileSync/src/com/salesforce/androidsdk/mobilesync/app/MobileSyncSDKManager.java @@ -52,6 +52,22 @@ public class MobileSyncSDKManager extends SmartStoreSDKManager { private static final String TAG = "MobileSyncSDKManager"; + /** + * Protected constructor. + * + * @param context Application context. + * @param mainActivity Activity that should be launched after the login flow. + * @param loginActivity Login activity. + * @param nativeLoginActivity Native login activity. + * @param googleCloudProjectId Google Cloud project ID for app attestation (nullable). + */ + protected MobileSyncSDKManager(Context context, Class mainActivity, + Class loginActivity, + Class nativeLoginActivity, + Long googleCloudProjectId) { + super(context, mainActivity, loginActivity, nativeLoginActivity, googleCloudProjectId); + } + /** * Protected constructor. * @@ -63,7 +79,7 @@ public class MobileSyncSDKManager extends SmartStoreSDKManager { protected MobileSyncSDKManager(Context context, Class mainActivity, Class loginActivity, Class nativeLoginActivity) { - super(context, mainActivity, loginActivity, nativeLoginActivity); + this(context, mainActivity, loginActivity, nativeLoginActivity, null); } private static void init(Context context, Class mainActivity, diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 81f8f85462..300edda251 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -64,6 +64,7 @@ import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat.RECEIVER_EXPORTED import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED import androidx.core.content.ContextCompat.registerReceiver +import androidx.core.net.toUri import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner @@ -97,6 +98,7 @@ import com.salesforce.androidsdk.auth.NativeLoginManager import com.salesforce.androidsdk.auth.OAuth2.LogoutReason import com.salesforce.androidsdk.auth.OAuth2.LogoutReason.UNKNOWN import com.salesforce.androidsdk.auth.OAuth2.revokeRefreshToken +import com.salesforce.androidsdk.auth.RemoteAccessConsumerKeyProvider import com.salesforce.androidsdk.auth.idp.SPConfig import com.salesforce.androidsdk.auth.idp.interfaces.IDPManager import com.salesforce.androidsdk.auth.idp.interfaces.SPManager @@ -176,6 +178,9 @@ import com.salesforce.androidsdk.security.interfaces.ScreenLockManager as Screen * @param context The Android context * @param mainActivity Activity that should be launched after the login flow * @param loginActivity Login activity + * @param googleCloudProjectId The Google Cloud Project ID to use with + * Google Play Integrity API and Salesforce App Attestation or null to + * disable both features */ open class SalesforceSDKManager protected constructor( @JvmField @@ -183,6 +188,7 @@ open class SalesforceSDKManager protected constructor( mainActivity: Class, private val loginActivity: Class? = null, internal val nativeLoginActivity: Class? = null, + googleCloudProjectId: Long? = null, ) : DefaultLifecycleObserver { constructor( @@ -229,50 +235,40 @@ open class SalesforceSDKManager protected constructor( /** * The client side implementation of the Salesforce App Attestation External - * Client App (ECA) Plugin or null when app attestation is disabled. + * Client App (ECA) Plugin or null when Salesforce App Attestation is + * disabled. * * This property is not intended for public use outside of Salesforce Mobile * SDK * * TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420 */ - @Volatile - var appAttestationClient: AppAttestationClient? = null - @VisibleForTesting - internal set - - /** Lock object for synchronized access to the app Attestation Client */ - private val appAttestationClientLock = Any() + val appAttestationClient: AppAttestationClient? by lazy { + googleCloudProjectId?.let { createAppAttestationClient(it) } + } /** - * Updates the Salesforce App Attestation ECA Plugin Client for the selected - * login server and matching Google Cloud Project ID. When using App - * Attestation, this value must match the linked Google Cloud Project ID - * for the app in Google Play Console's Play Integrity API and provided to - * the Salesforce App Attestation External Client App Plugin. + * Creates the Salesforce App Attestation ECA Plugin Client for the selected + * Google Cloud Project ID. When using Salesforce App Attestation, this + * value must match the linked Google Cloud Project ID for the app in Google + * Play Console's Play Integrity API and provided to the Salesforce App + * Attestation External Client App Plugin. * - * @param apiHostName The Salesforce App Attestation External Client App - * (ECA) Plugin Challenge API Host Name. This usually matches the selected - * login server * @param googleCloudProjectId The Google Cloud Project ID or null to * disable Salesforce App Attestation */ - fun updateAppAttestationClient( - apiHostName: String, + fun createAppAttestationClient( googleCloudProjectId: Long? = null - ) { - synchronized(appAttestationClientLock) { - appAttestationClient = googleCloudProjectId?.let { appAttestationGoogleCloudProjectId -> - AppAttestationClient( - context = appContext, - apiHostName = apiHostName, - deviceId = deviceId, - googleCloudProjectId = appAttestationGoogleCloudProjectId, - remoteAccessConsumerKey = getBootConfig(appContext).remoteAccessConsumerKey, - restClient = clientManager.peekUnauthenticatedRestClient() - ) - } - } + ) = googleCloudProjectId?.let { appAttestationGoogleCloudProjectId -> + AppAttestationClient( + context = appContext, + deviceId = deviceId, + googleCloudProjectId = appAttestationGoogleCloudProjectId, + remoteAccessConsumerKeyProvider = RemoteAccessConsumerKeyProvider { loginServer -> + resolveOAuthConfigForLoginServer(loginServer).consumerKey + }, + restClient = clientManager.peekUnauthenticatedRestClient() + ) } /** @@ -294,6 +290,32 @@ open class SalesforceSDKManager protected constructor( internal var debugOverrideAppConfig: OAuthConfig? = null + /** + * Resolves the OAuth configuration for the specified login server. + * + * Resolution order: + * 1. Debug override configuration (when [isDebugBuild] is true and override + * is set) + * 2. Dynamic app configuration for the login host via + * [appConfigForLoginHost] + * 3. Static boot configuration from bootconfig.xml + * + * This allows apps to use different OAuth configs per server while + * supporting debug overrides for development/testing. + * + * @param loginServer The login server URL + * @return The OAuth configuration for the specified server + */ + internal suspend fun resolveOAuthConfigForLoginServer( + loginServer: String + ): OAuthConfig { + val debugOverride = debugOverrideAppConfig + return when { + isDebugBuild && debugOverride != null -> debugOverride + else -> appConfigForLoginHost(loginServer) ?: OAuthConfig(getBootConfig(appContext)) + } + } + /** The class for the account switcher activity */ var accountSwitcherActivityClass = AccountSwitcherActivity::class.java @@ -1528,7 +1550,7 @@ open class SalesforceSDKManager protected constructor( } /** Indicates if this is a debug build */ - internal val isDebugBuild + internal open val isDebugBuild get() = DEBUG @@ -1717,12 +1739,16 @@ open class SalesforceSDKManager protected constructor( * @param context The Android context * @param mainActivity The app's main activity class * @param loginActivity The app login activity class + * @param googleCloudProjectId The Google Cloud Project ID to use with + * Google Play Integrity API and Salesforce App Attestation or null to + * disable both features */ private fun init( context: Context, mainActivity: Class, loginActivity: Class? = null, nativeLoginActivity: Class? = null, + googleCloudProjectId: Long? = null, ) { if (INSTANCE == null) { INSTANCE = SalesforceSDKManager( @@ -1730,6 +1756,7 @@ open class SalesforceSDKManager protected constructor( mainActivity, loginActivity, nativeLoginActivity, + googleCloudProjectId, ) } initInternal(context) @@ -1958,6 +1985,9 @@ open class SalesforceSDKManager protected constructor( shareBrowserSessionEnabled = false ) + // Disable Salesforce App Attestation for login servers that are not My Domain servers. + appAttestationClient?.apiHostName = null + return@withTimeoutOrNull } @@ -1966,6 +1996,9 @@ open class SalesforceSDKManager protected constructor( browserLoginEnabled = authConfig?.isBrowserLoginEnabled ?: false, shareBrowserSessionEnabled = authConfig?.isShareBrowserSessionEnabled ?: false ) + + // Consider enabling Salesforce App Attestation for login servers that are My Domain servers. + appAttestationClient?.apiHostName = loginServer.toUri().host } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt index ca4d2061ac..caed1610c9 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AppAttestationClient.kt @@ -46,6 +46,22 @@ import java.nio.charset.StandardCharsets.UTF_8 import java.security.MessageDigest import java.util.Base64 +/** + * Provides the Salesforce External Client App (ECA) remote access consumer key. + * This is typically sourced from the boot configuration. + * + * This interface is not intended for public use outside of Salesforce Mobile + * SDK. + */ +fun interface RemoteAccessConsumerKeyProvider { + /** + * Returns the current remote access consumer key or null if not available. + * @param loginServer The login server + * @return The remote access consumer key or null if not available + */ + suspend fun getRemoteConsumerKey(loginServer: String): String? +} + /** * App attestation features supporting the Salesforce App Attestation External * Client App (ECA) Plugin, the Salesforce Challenge API, Google Play Integrity @@ -55,15 +71,14 @@ import java.util.Base64 * * TODO: Make this class internal once Java support is removed. ECJ20260421 * - * @param apiHostName The Salesforce App Attestation Challenge API host * @param deviceId The device id, usually provided by the Salesforce SDK Manager * @param googleCloudProjectId The Google Cloud Project ID used with Google Play * Integrity API * @param integrityManager The Google Play App Integrity API Integrity Manager. * This parameter is intended for testing purposes only. Defaults to a new * instance - * @param remoteAccessConsumerKey The Salesforce Connected App (CA) or External - * Client App (ECA)remote access consumer key, usually provided by the boot + * @param remoteAccessConsumerKeyProvider Provides the Salesforce External + * Client App (ECA) remote access consumer key, usually sourced from the boot * config * @param restClient The REST client, usually provided by the Salesforce SDK * Manager's unauthenticated REST client @@ -71,19 +86,20 @@ import java.util.Base64 class AppAttestationClient( context: Context, @property:VisibleForTesting - internal val apiHostName: String, - @property:VisibleForTesting internal val deviceId: String, @property:VisibleForTesting internal val googleCloudProjectId: Long, @property:VisibleForTesting internal val integrityManager: StandardIntegrityManager = createStandard(context), @property:VisibleForTesting - internal val remoteAccessConsumerKey: String, + internal val remoteAccessConsumerKeyProvider: RemoteAccessConsumerKeyProvider, @property:VisibleForTesting internal val restClient: RestClient, ) { + /** The Salesforce App Attestation Challenge API host or null to disable Salesforce App Attestation */ + @Volatile + internal var apiHostName: String? = null /** The Google Play Integrity API Token Provider */ @VisibleForTesting @@ -222,24 +238,41 @@ class AppAttestationClient( * Fetches a new "Challenge" from the Salesforce App Attestation External * Client App (ECA) Plug-In. * - * This method is not intended for public use outside of Salesforce Mobile - * SDK. - * - * TODO: Make this Kotlin-internal once it is no longer referenced by Java. ECJ20260420 - * - * @return The Salesforce App Attestation ECA Plug-In's "Challenge" + * @return The Salesforce App Attestation ECA Plug-In challenge, or null if + * App Attestation is disabled (apiHostName is null) or the remote access + * consumer key is unavailable + * @throws java.io.IOException if the network request fails + * @throws org.json.JSONException if the response cannot be parsed */ - fun fetchMobileAppAttestationChallenge(): String { + internal suspend fun fetchMobileAppAttestationChallenge(): String? { // Create the Salesforce App Attestation Challenge API client and fetch a new challenge. + val apiHost = apiHostName ?: return null val appAttestationChallengeApiClient = AppAttestationChallengeApiClient( - apiHostName = apiHostName, + apiHostName = apiHost, restClient = restClient ) return appAttestationChallengeApiClient.fetchChallenge( attestationId = deviceId, - remoteConsumerKey = remoteAccessConsumerKey + remoteConsumerKey = remoteAccessConsumerKeyProvider.getRemoteConsumerKey(apiHost) ?: return null ) } + + /** + * Fetches a new "Challenge" from the Salesforce App Attestation External + * Client App (ECA) Plug-In. + * + * This method is not intended for public use outside of Salesforce Mobile + * SDK. + * + * @return The Salesforce App Attestation ECA Plug-In challenge, or null if + * App Attestation is disabled (apiHostName is null) or the remote access + * consumer key is unavailable + * @throws java.io.IOException if the network request fails + * @throws org.json.JSONException if the response cannot be parsed + */ + fun fetchMobileAppAttestationChallengeBlocking(): String? = runBlocking { + fetchMobileAppAttestationChallenge() + } } /** diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt index 4f99a177e1..f934911fab 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/NativeLoginManager.kt @@ -170,7 +170,7 @@ internal class NativeLoginManager( AUTHORIZATION to "$AUTH_AUTHORIZATION_VALUE_BASIC $encodedCreds", ) val attestationValue = SalesforceSDKManager.getInstance().appAttestationClient?.run { - val challenge = fetchMobileAppAttestationChallenge() + val challenge = fetchMobileAppAttestationChallenge() ?: return@run null createAppAttestation(challenge) ?: return@run null } val authRequestBody = createRequestBody( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index 6b3b94b2ca..15263955c3 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -580,7 +580,7 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce sb.append(QUESTION).append(DEVICE_ID).append(EQUAL).append(salesforceSdkManager.getDeviceId()); final AppAttestationClient appAttestationClient = salesforceSdkManager.getAppAttestationClient(); - final String challenge = appAttestationClient != null ? appAttestationClient.fetchMobileAppAttestationChallenge() : null; + final String challenge = appAttestationClient != null ? appAttestationClient.fetchMobileAppAttestationChallengeBlocking() : null; final String attestationValue = challenge != null ? appAttestationClient.createAppAttestationBlocking(challenge) : null; if (attestationValue != null) { // Note: The attestation value is appended to the token endpoint diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt index 104cccb4f1..bfd0cef909 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt @@ -115,7 +115,7 @@ internal class IDPAuthCodeHelper @VisibleForTesting internal constructor( // Add Salesforce Mobile App Attestation parameter to authorization URL if applicable. val additionalParams = appAttestationClient?.run { - val challenge = fetchMobileAppAttestationChallenge() + val challenge = fetchMobileAppAttestationChallenge() ?: return@run null val attestation = createAppAttestation(challenge) ?: return@run null mapOf(ATTESTATION to attestation) } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index a72b444f31..e16ae209aa 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -462,9 +462,8 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { // Populate the additional parameter map with app attestation, if applicable. val additionalParameters = mutableMapOf() sdkManager.appAttestationClient?.run { - val challenge = fetchMobileAppAttestationChallenge() - val attestation = createAppAttestation(challenge) - if (attestation == null) return@run + val challenge = fetchMobileAppAttestationChallenge() ?: return@run + val attestation = createAppAttestation(challenge) ?: return@run additionalParameters[ATTESTATION] = attestation } @@ -502,15 +501,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { // Perform heavy work (config fetch, URL generation) on the IO dispatcher. val (browserTabUrl, webViewUrl) = withContext(coroutineContext) { - val debugOverrideAppConfig = sdkManager.debugOverrideAppConfig - with(sdkManager) { - oAuthConfig = when { - // Used by LoginOptions - isDebugBuild && debugOverrideAppConfig != null -> debugOverrideAppConfig - // Check if app has a config and fallback to bootconfig file. - else -> appConfigForLoginHost(server) ?: OAuthConfig(bootConfig) - } - } + oAuthConfig = sdkManager.resolveOAuthConfigForLoginServer(server) val jwtFlow = !jwt.isNullOrBlank() && !authCodeForJwtFlow.isNullOrBlank() val additionalParams = when { @@ -523,7 +514,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { // Populate the additional parameter map with app attestation, if applicable. sdkManager.appAttestationClient?.run { - val challenge = fetchMobileAppAttestationChallenge() + val challenge = fetchMobileAppAttestationChallenge() ?: return@run val attestation = createAppAttestation(challenge) ?: return@run additionalParams[ATTESTATION] = attestation } diff --git a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java index eb5ab07d23..5184e0b449 100644 --- a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java +++ b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java @@ -66,6 +66,22 @@ public class SmartStoreSDKManager extends SalesforceSDKManager { private static final String TAG = "SmartStoreSDKManager"; public static final String GLOBAL_SUFFIX = "_global"; + /** + * Protected constructor. + * + * @param context Application context. + * @param mainActivity Activity that should be launched after the login flow. + * @param loginActivity Login activity. + * @param nativeLoginActivity Native login activity. + * @param googleCloudProjectId Google Cloud project ID for app attestation (nullable). + */ + protected SmartStoreSDKManager(Context context, Class mainActivity, + Class loginActivity, + Class nativeLoginActivity, + Long googleCloudProjectId) { + super(context, mainActivity, loginActivity, nativeLoginActivity, googleCloudProjectId); + } + /** * Protected constructor. * @@ -77,7 +93,7 @@ public class SmartStoreSDKManager extends SalesforceSDKManager { protected SmartStoreSDKManager(Context context, Class mainActivity, Class loginActivity, Class nativeLoginActivity) { - super(context, mainActivity, loginActivity, nativeLoginActivity); + this(context, mainActivity, loginActivity, nativeLoginActivity, null); } private static void init(Context context, Class mainActivity, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerOAuthConfigResolverTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerOAuthConfigResolverTest.kt new file mode 100644 index 0000000000..14a64c8bc0 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerOAuthConfigResolverTest.kt @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.app + +import android.app.Activity +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.salesforce.androidsdk.MainActivity +import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.BootConfig.getBootConfig +import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.ui.LoginActivity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for SalesforceSDKManager OAuth config resolution methods. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class SalesforceSDKManagerOAuthConfigResolverTest { + + private lateinit var sdkManager: TestSalesforceSDKManager + private lateinit var targetContext: Context + private lateinit var bootConfig: BootConfig + + @Before + fun setUp() { + targetContext = getInstrumentation().targetContext + bootConfig = getBootConfig(targetContext) + TestSalesforceSDKManager.init(targetContext, MainActivity::class.java) + sdkManager = TestSalesforceSDKManager.getInstance() as TestSalesforceSDKManager + sdkManager.isTestRun = true + } + + @After + fun tearDown() { + // Reset state + sdkManager.debugOverrideAppConfig = null + sdkManager.appConfigForLoginHost = { OAuthConfig(getBootConfig(targetContext)) } + sdkManager.isDebugBuildOverride = null + TestSalesforceSDKManager.resetInstance() + } + + @Test + fun test_givenDebugOverride_whenResolveOAuthConfig_thenReturnsDebugOverride() = runBlocking { + // Given: Debug override config is set + val overrideConfig = OAuthConfig( + consumerKey = "debug-consumer-key", + redirectUri = "debug://callback", + scopes = listOf("api", "web") + ) + sdkManager.debugOverrideAppConfig = overrideConfig + + // When: Resolving OAuth config for any server + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com") + + // Then: Debug override config is returned + assertEquals("Debug override consumer key should be returned", "debug-consumer-key", result.consumerKey) + assertEquals("Debug override redirect URI should be returned", "debug://callback", result.redirectUri) + } + + @Test + fun test_givenNoDebugOverrideAndAppConfig_whenResolveOAuthConfig_thenReturnsAppConfig() = runBlocking { + // Given: No debug override but app config returns custom config + val appConfig = OAuthConfig( + consumerKey = "app-consumer-key", + redirectUri = "app://callback", + scopes = listOf("api") + ) + sdkManager.debugOverrideAppConfig = null + sdkManager.appConfigForLoginHost = { appConfig } + + // When: Resolving OAuth config for a server + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com") + + // Then: App config is returned + assertEquals("App config consumer key should be returned", "app-consumer-key", result.consumerKey) + assertEquals("App config redirect URI should be returned", "app://callback", result.redirectUri) + } + + @Test + fun test_givenNoDebugOverrideAndNoAppConfig_whenResolveOAuthConfig_thenReturnsBootConfig() = runBlocking { + // Given: No debug override and app config returns null + sdkManager.debugOverrideAppConfig = null + sdkManager.appConfigForLoginHost = { null } + + // When: Resolving OAuth config for a server + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com") + + // Then: Boot config is returned + assertEquals( + "Boot config consumer key should be returned", + bootConfig.remoteAccessConsumerKey, + result.consumerKey + ) + assertEquals( + "Boot config redirect URI should be returned", + bootConfig.oauthRedirectURI, + result.redirectUri + ) + } + + @Test + fun test_givenDebugBuildButNoOverride_whenResolveOAuthConfig_thenReturnsAppConfig() = runBlocking { + // Given: Debug build but no debug override config set + val appConfig = OAuthConfig( + consumerKey = "app-consumer-key", + redirectUri = "app://callback", + scopes = listOf("api") + ) + sdkManager.debugOverrideAppConfig = null + sdkManager.appConfigForLoginHost = { appConfig } + + // When: Resolving OAuth config + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com") + + // Then: App config is returned (debug override not applied without override set) + assertEquals("App config should be used", "app-consumer-key", result.consumerKey) + } + + @Test + fun test_givenOAuthConfig_whenExtractConsumerKey_thenReturnsConsumerKey() = runBlocking { + // Given: App config with specific consumer key + val appConfig = OAuthConfig( + consumerKey = "expected-consumer-key", + redirectUri = "app://callback", + scopes = listOf("api") + ) + sdkManager.debugOverrideAppConfig = null + sdkManager.appConfigForLoginHost = { appConfig } + + // When: Resolving OAuth config and extracting consumer key + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com").consumerKey + + // Then: Consumer key from the resolved config is returned + assertEquals("Consumer key should match", "expected-consumer-key", result) + } + + @Test + fun test_givenDebugOverride_whenExtractConsumerKey_thenReturnsDebugOverrideKey() = runBlocking { + // Given: Debug override with specific consumer key + val overrideConfig = OAuthConfig( + consumerKey = "debug-key", + redirectUri = "debug://callback", + scopes = listOf("api") + ) + sdkManager.debugOverrideAppConfig = overrideConfig + + // When: Resolving OAuth config and extracting consumer key + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com").consumerKey + + // Then: Debug override consumer key is returned + assertEquals("Debug override consumer key should be returned", "debug-key", result) + } + + @Test + fun test_givenDifferentServers_whenResolveOAuthConfig_thenPassesServerToAppConfig() = runBlocking { + // Given: App config that returns different configs per server + var lastServer: String? = null + sdkManager.debugOverrideAppConfig = null + sdkManager.appConfigForLoginHost = { server -> + lastServer = server + OAuthConfig( + consumerKey = "key-for-$server", + redirectUri = "app://callback", + scopes = listOf("api") + ) + } + + // When: Resolving OAuth config for specific server + val result = sdkManager.resolveOAuthConfigForLoginServer("https://custom.salesforce.com") + + // Then: Server parameter is passed to appConfigForLoginHost + assertEquals("Server should be passed to appConfigForLoginHost", "https://custom.salesforce.com", lastServer) + assertEquals("Consumer key should include server", "key-for-https://custom.salesforce.com", result.consumerKey) + } + + @Test + fun test_givenReleaseBuildWithDebugOverride_whenResolveOAuthConfig_thenIgnoresDebugOverride() = runBlocking { + // Given: Release build (isDebugBuild = false) with debug override set + val overrideConfig = OAuthConfig( + consumerKey = "debug-consumer-key", + redirectUri = "debug://callback", + scopes = listOf("api") + ) + val appConfig = OAuthConfig( + consumerKey = "app-consumer-key", + redirectUri = "app://callback", + scopes = listOf("api") + ) + sdkManager.isDebugBuildOverride = false + sdkManager.debugOverrideAppConfig = overrideConfig + sdkManager.appConfigForLoginHost = { appConfig } + + // When: Resolving OAuth config + val result = sdkManager.resolveOAuthConfigForLoginServer("https://test.salesforce.com") + + // Then: Debug override is ignored and app config is returned + assertEquals("App config consumer key should be returned", "app-consumer-key", result.consumerKey) + assertEquals("App config redirect URI should be returned", "app://callback", result.redirectUri) + } + + /** + * Test version of SalesforceSDKManager that doesn't interfere with other tests. + */ + private class TestSalesforceSDKManager( + context: Context, + mainActivity: Class, + loginActivity: Class, + ) : SalesforceSDKManager(context, mainActivity, loginActivity) { + + var isDebugBuildOverride: Boolean? = null + + override val isDebugBuild: Boolean + get() = isDebugBuildOverride ?: super.isDebugBuild + + companion object { + private var TEST_INSTANCE: TestSalesforceSDKManager? = null + + fun init(context: Context, mainActivity: Class) { + if (TEST_INSTANCE == null) { + TEST_INSTANCE = TestSalesforceSDKManager(context, mainActivity, LoginActivity::class.java) + } + initInternal(context) + } + + fun getInstance(): SalesforceSDKManager { + return TEST_INSTANCE ?: throw RuntimeException( + "Applications need to call TestSalesforceSDKManager.init() first." + ) + } + + fun resetInstance() { + TEST_INSTANCE = null + } + } + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt index 81b48f7a1e..169146ec8b 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.salesforce.androidsdk.auth.HttpAccess +import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL @@ -13,6 +14,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs +import io.mockk.unmockkAll import kotlinx.coroutines.runBlocking import okhttp3.Call import okhttp3.MediaType.Companion.toMediaType @@ -37,38 +39,69 @@ import org.junit.runner.RunWith class SalesforceSDKManagerTests { private val responseBodyString = - "{\"id\":\"https://login.ietf.reserved.test.example.com/id/1234567890ABCDEFGH/ABCDEFGH1234567890\",\"asserted_user\":true,\"user_id\":\"ABCDEFGH1234567890\",\"organization_id\":\"1234567890ABCDEFGH\",\"username\":\"ietf_reserved_test_domain@example.com\",\"nick_name\":\"username\",\"display_name\":\"Test User\",\"email\":\"ietf_reserved_test_domain@example.com\",\"email_verified\":true,\"first_name\":\"First\",\"last_name\":\"Last\",\"timezone\":\"America/Los_Angeles\",\"photos\":{\"picture\":\"https://ietf.reserved.test.example.com/profilephoto/ZYXWVUTSRQPONML/F\",\"thumbnail\":\"https://ietf.reserved.test.example.com/profilephoto/ZYXWVUTSRQPONML/T\"},\"addr_street\":null,\"addr_city\":null,\"addr_state\":null,\"addr_country\":null,\"addr_zip\":null,\"mobile_phone\":null,\"mobile_phone_verified\":true,\"is_lightning_login_user\":false,\"status\":{\"created_date\":null,\"body\":null},\"urls\":{\"enterprise\":\"https://ietf.reserved.test.example.com/services/Soap/c/{version}/0987654321EDCVA\",\"metadata\":\"https://ietf.reserved.test.example.com/services/Soap/m/{version}/0987654321EDCVA\",\"partner\":\"https://ietf.reserved.test.example.com/services/Soap/u/{version}/0987654321EDCVA\",\"rest\":\"https://ietf.reserved.test.example.com/services/data/v{version}/\",\"sobjects\":\"https://ietf.reserved.test.example.com/services/data/v{version}/sobjects/\",\"search\":\"https://ietf.reserved.test.example.com/services/data/v{version}/search/\",\"query\":\"https://ietf.reserved.test.example.com/services/data/v{version}/query/\",\"recent\":\"https://ietf.reserved.test.example.com/services/data/v{version}/recent/\",\"tooling_soap\":\"https://ietf.reserved.test.example.com/services/Soap/T/{version}/0987654321EDCVA\",\"tooling_rest\":\"https://ietf.reserved.test.example.com/services/data/v{version}/tooling/\",\"profile\":\"https://ietf.reserved.test.example.com/ABCDEFGH1234567890\",\"feeds\":\"https://ietf.reserved.test.example.com/services/data/v{version}/chatter/feeds\",\"groups\":\"https://ietf.reserved.test.example.com/services/data/v{version}/chatter/groups\",\"users\":\"https://ietf.reserved.test.example.com/services/data/v{version}/chatter/users\",\"feed_items\":\"https://ietf.reserved.test.example.com/services/data/v{version}/chatter/feed-items\",\"feed_elements\":\"https://ietf.reserved.test.example.com/services/data/v{version}/chatter/feed-elements\",\"custom_domain\":\"https://ietf.reserved.test.example.com\"},\"active\":true,\"user_type\":\"STANDARD\",\"language\":\"en_US\",\"locale\":\"en_US\",\"utcOffset\":-28800000,\"last_modified_date\":\"2025-02-28T18:14:06Z\"}" + "{\"MobileSDK\":{\"UseAndroidNativeBrowserForAuthentication\":false,\"shareBrowserSessionAndroid\":false}}" - private val responseBody = mockk().apply { - every { contentType() } returns "application/json;charset=UTF-8".toMediaType() - every { bytes() } returns this@SalesforceSDKManagerTests.responseBodyString.toByteArray() - } + private lateinit var responseBody: ResponseBody + private lateinit var response: Response + private lateinit var call: Call + private lateinit var okHttpClient: OkHttpClient + private lateinit var httpAccess: HttpAccess - private val response = mockk().apply { - every { isSuccessful } returns true - every { body } returns this@SalesforceSDKManagerTests.responseBody - every { close() } just runs - } + @Before + fun setup() { + // Ensure the singleton SalesforceSDKManager is properly initialized + // This is needed because AuthConfigUtil.getMyDomainAuthConfig() uses the singleton + try { + SalesforceSDKManager.getInstance() + } catch (e: RuntimeException) { + // Only initialize if this is the expected "not initialized" exception + // Re-throw any other RuntimeException (memory issues, context problems, etc.) + if (e.message?.contains("SalesforceSDKManager.init") == true) { + SalesforceSDKManager.initNative( + getInstrumentation().targetContext, + LoginActivity::class.java + ) + } else { + throw e + } + } - private val call = mockk().apply { - every { execute() } returns this@SalesforceSDKManagerTests.response - } + // Initialize mocks fresh for each test to avoid stale mock state + // Using strict mocking (no relaxed = true) to catch unexpected method calls + responseBody = mockk().apply { + every { contentType() } returns "application/json;charset=UTF-8".toMediaType() + every { bytes() } returns this@SalesforceSDKManagerTests.responseBodyString.toByteArray() + } - private val okHttpClient = mockk().apply { - every { newCall(any()) } returns this@SalesforceSDKManagerTests.call - } + response = mockk().apply { + every { isSuccessful } returns true + every { body } returns this@SalesforceSDKManagerTests.responseBody + every { close() } just runs + } - private val httpAccess = mockk().apply { - every { getOkHttpClient() } returns this@SalesforceSDKManagerTests.okHttpClient - } + call = mockk().apply { + every { execute() } returns this@SalesforceSDKManagerTests.response + } - @Before - fun setup() { + okHttpClient = mockk().apply { + every { newCall(any()) } returns this@SalesforceSDKManagerTests.call + } + + httpAccess = mockk().apply { + every { getOkHttpClient() } returns this@SalesforceSDKManagerTests.okHttpClient + } } @After fun teardown() { - SalesforceSDKManager.getInstance().loginServerManager.reset() + // Reset all singleton state to ensure test isolation + // This prevents state leakage between tests + SalesforceSDKManager.getInstance().apply { + loginServerManager.reset() + isBrowserLoginEnabled = false + isShareBrowserSessionEnabled = false + } + unmockkAll() } @Test @@ -193,6 +226,8 @@ class SalesforceSDKManagerTests { assertFalse(SalesforceSDKManager.getInstance().isBrowserLoginEnabled) assertFalse(SalesforceSDKManager.getInstance().isShareBrowserSessionEnabled) + + // No verification for invalid URL - the fetch is skipped } @Test @@ -253,6 +288,80 @@ class SalesforceSDKManagerTests { assertFalse(SalesforceSDKManager.getInstance().isShareBrowserSessionEnabled) } + @Test + fun salesforceSdkManager_ClearsAppAttestationHostName_ForNonMyDomainServer() { + + // Create test instance with production server (non-My Domain) + val salesforceSdkManager = TestSalesforceSDKManagerWithAttestation( + context = getInstrumentation().targetContext, + mainActivity = LoginActivity::class.java, + loginActivity = LoginActivity::class.java, + googleCloudProjectId = 123456L, + testLoginServer = LoginServer( + "Production", + PRODUCTION_LOGIN_URL, + false + ) + ) + + // Verify app attestation client exists and get non-null reference + val appAttestationClient = requireNotNull(salesforceSdkManager.appAttestationClient) { + "App attestation client should not be null" + } + + // Set initial hostname value + appAttestationClient.apiHostName = "test.example.com" + assertEquals("test.example.com", appAttestationClient.apiHostName) + + runBlocking { + salesforceSdkManager.fetchAuthenticationConfiguration( + httpAccess = httpAccess, + ) { + /* Completion Does Not Require Verification */ + }.join() + } + + // Verify hostname was cleared for non-My Domain server + assertNull(appAttestationClient.apiHostName) + } + + @Test + fun salesforceSdkManager_SetsAppAttestationHostName_ForMyDomainServer() { + + // Create test instance with My Domain server + val testLoginServer = LoginServer( + "Example", + "https://www.example.com", + true + ) + val salesforceSdkManager = TestSalesforceSDKManagerWithAttestation( + context = getInstrumentation().targetContext, + mainActivity = LoginActivity::class.java, + loginActivity = LoginActivity::class.java, + googleCloudProjectId = 123456L, + testLoginServer = testLoginServer + ) + + // Verify app attestation client exists and get non-null reference + val appAttestationClient = requireNotNull(salesforceSdkManager.appAttestationClient) { + "App attestation client should not be null" + } + + // Initial hostname should be null + assertNull(appAttestationClient.apiHostName) + + runBlocking { + salesforceSdkManager.fetchAuthenticationConfiguration( + httpAccess = httpAccess, + ) { + /* Completion Does Not Require Verification */ + }.join() + } + + // Verify hostname was set to the My Domain server host + assertEquals("www.example.com", appAttestationClient.apiHostName) + } + @Test fun getDevActions_ReturnsAllActions_ForNonLoginActivity() { // Arrange @@ -292,28 +401,116 @@ class SalesforceSDKManagerTests { } @Test - fun salesforceSdkManager_updateAppAttestationClient_setsAndUnsetsAppAttestationClientForGoogleCloudProjectId() { + fun salesforceSdkManager_appAttestationClient_isNullWhenNoGoogleCloudProjectIdProvided() { - val salesforceSdkManager = SalesforceSDKManager( - context = getInstrumentation().targetContext, - mainActivity = LoginActivity::class.java, /* Any Activity Class */ - loginActivity = LoginActivity::class.java, - ) + val salesforceSdkManager = createTestSalesforceSDKManager() - salesforceSdkManager.updateAppAttestationClient( - apiHostName = "login.example.com", - googleCloudProjectId = 123456 + assertNull( + "appAttestationClient should be null when no googleCloudProjectId is provided.", + salesforceSdkManager.appAttestationClient, ) + } + + @Test + fun salesforceSdkManager_appAttestationClient_isCreatedWhenGoogleCloudProjectIdProvided() = runBlocking { + + val salesforceSdkManager = createTestSalesforceSDKManager(googleCloudProjectId = 123456L) val appAttestationClient = salesforceSdkManager.appAttestationClient + assertNotNull( + "appAttestationClient should be non-null when googleCloudProjectId is provided.", + appAttestationClient, + ) assertEquals(123456L, appAttestationClient?.googleCloudProjectId) - assertEquals("login.example.com", appAttestationClient?.apiHostName) assertNotNull(appAttestationClient?.deviceId) - assertEquals("__CONSUMER_KEY__", appAttestationClient?.remoteAccessConsumerKey) + assertEquals("__CONSUMER_KEY__", appAttestationClient?.remoteAccessConsumerKeyProvider?.getRemoteConsumerKey("https://login.salesforce.com")) assertNotNull(appAttestationClient?.restClient) + // apiHostName starts null — it is set later by fetchAuthenticationConfiguration. + assertNull( + "apiHostName should initially be null before fetchAuthenticationConfiguration is called.", + appAttestationClient?.apiHostName, + ) + } + + @Test + fun salesforceSdkManager_createAppAttestationClient_returnsNullForNullGoogleCloudProjectId() { + + val salesforceSdkManager = createTestSalesforceSDKManager() + + assertNull(salesforceSdkManager.createAppAttestationClient(googleCloudProjectId = null)) + } + + @Test + fun salesforceSdkManager_createAppAttestationClient_returnsNullWhenCalledWithoutParameter() { - salesforceSdkManager.updateAppAttestationClient("https://login.example.com" /* null default */) + val salesforceSdkManager = createTestSalesforceSDKManager() - assertNull(salesforceSdkManager.appAttestationClient) + assertNull(salesforceSdkManager.createAppAttestationClient()) + } + + @Test + fun salesforceSdkManager_createAppAttestationClient_returnsClientForNonNullGoogleCloudProjectId() { + + val salesforceSdkManager = createTestSalesforceSDKManager() + + val client = salesforceSdkManager.createAppAttestationClient(googleCloudProjectId = 654321L) + assertNotNull(client) + assertEquals(654321L, client?.googleCloudProjectId) + } + + + /** + * Helper to create a test [SalesforceSDKManager] instance with optional + * [googleCloudProjectId] for app attestation tests. + */ + private fun createTestSalesforceSDKManager( + googleCloudProjectId: Long? = null + ): SalesforceSDKManager = if (googleCloudProjectId != null) { + TestSalesforceSDKManagerWithAttestation( + context = getInstrumentation().targetContext, + mainActivity = LoginActivity::class.java, + loginActivity = LoginActivity::class.java, + googleCloudProjectId = googleCloudProjectId, + ) + } else { + SalesforceSDKManager( + context = getInstrumentation().targetContext, + mainActivity = LoginActivity::class.java, + loginActivity = LoginActivity::class.java, + ) + } + + /** + * A minimal subclass of [SalesforceSDKManager] that exposes the protected + * primary constructor so that tests can supply a [googleCloudProjectId]. + * + * This subclass also overrides [loginServerManager] to provide an + * isolated test instance that doesn't share state via SharedPreferences. + */ + private class TestSalesforceSDKManagerWithAttestation( + context: android.content.Context, + mainActivity: Class, + loginActivity: Class? = null, + googleCloudProjectId: Long? = null, + private val testLoginServer: LoginServer? = null, + ) : SalesforceSDKManager(context, mainActivity, loginActivity, null, googleCloudProjectId) { + + /** + * Override to provide a test-specific LoginServerManager that uses + * in-memory storage instead of SharedPreferences for test isolation. + */ + override val loginServerManager: LoginServerManager by lazy { + // Create a mock that doesn't use SharedPreferences + mockk(relaxed = true).apply { + // Return the test login server when asked + every { selectedLoginServer } returns (testLoginServer ?: LoginServer( + "Test", + "https://test.example.com", + false + )) + // No-op for reset() to avoid SharedPreferences access + every { reset() } just runs + } + } } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt index 75a5db88ef..a424c51bb7 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AppAttestationClientTest.kt @@ -45,6 +45,7 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json @@ -267,7 +268,7 @@ class AppAttestationClientTest { } @Test - fun appAttestationClient_fetchMobileAppAttestationChallenge_OnSuccess_ReturnsChallenge() { + fun appAttestationClient_fetchMobileAppAttestationChallenge_OnSuccess_ReturnsChallenge() = runTest { val requestSlot = slot() val restClient = createRestClientReturning( @@ -279,6 +280,8 @@ class AppAttestationClientTest { val result = appAttestationClient.fetchMobileAppAttestationChallenge() assertEquals(TEST_CHALLENGE_VALUE, result) + verify(exactly = 1) { restClient.sendSync(any()) } + assertTrue("Request slot should have captured a request", requestSlot.isCaptured) val requestedPath = requestSlot.captured.path assertTrue( "Request URL should target the attestation challenge endpoint at '$TEST_API_HOST_NAME' but was '$requestedPath'.", @@ -292,7 +295,6 @@ class AppAttestationClientTest { "Request URL should contain 'consumerKey=$TEST_REMOTE_ACCESS_CONSUMER_KEY' but was '$requestedPath'.", requestedPath.contains("consumerKey=$TEST_REMOTE_ACCESS_CONSUMER_KEY"), ) - verify(exactly = 1) { restClient.sendSync(any()) } } @Test @@ -304,7 +306,9 @@ class AppAttestationClientTest { val appAttestationClient = createAppAttestationClientForTest(restClient = restClient) assertThrows(AppAttestationChallengeApiException::class.java) { - appAttestationClient.fetchMobileAppAttestationChallenge() + runBlocking { + appAttestationClient.fetchMobileAppAttestationChallenge() + } } } @@ -317,10 +321,56 @@ class AppAttestationClientTest { val appAttestationClient = createAppAttestationClientForTest(restClient = restClient) assertThrows(AppAttestationChallengeApiException::class.java) { - appAttestationClient.fetchMobileAppAttestationChallenge() + runBlocking { + appAttestationClient.fetchMobileAppAttestationChallenge() + } } } + @Test + fun appAttestationClient_fetchMobileAppAttestationChallenge_WhenApiHostNameIsNull_ReturnsNull() = runTest { + + val appAttestationClient = createAppAttestationClientForTest(apiHostName = null) + + val result = appAttestationClient.fetchMobileAppAttestationChallenge() + + assertNull(result) + } + + @Test + fun appAttestationClient_fetchMobileAppAttestationChallenge_WhenRemoteConsumerKeyIsNull_ReturnsNull() = runTest { + + val appAttestationClient = createAppAttestationClientForTest( + remoteAccessConsumerKeyProvider = RemoteAccessConsumerKeyProvider { null } + ) + + val result = appAttestationClient.fetchMobileAppAttestationChallenge() + + assertNull(result) + } + + // region Blocking Wrapper Test + + /** + * Tests the blocking wrapper delegates to the suspend function correctly. + * Functionality is fully covered by the suspend function tests. + */ + @Test + fun appAttestationClient_fetchMobileAppAttestationChallengeBlocking_DelegatesToSuspendFunction() { + + val restClient = createRestClientReturning( + restResponse = createRestResponse(body = TEST_CHALLENGE_VALUE, success = true), + ) + val appAttestationClient = createAppAttestationClientForTest(restClient = restClient) + + val result = appAttestationClient.fetchMobileAppAttestationChallengeBlocking() + + assertEquals(TEST_CHALLENGE_VALUE, result) + verify(exactly = 1) { restClient.sendSync(any()) } + } + + // endregion Blocking Wrapper Test + @Test fun oAuthAuthorizationAttestation_encode_returnsSuccessfully() { @@ -356,15 +406,16 @@ class AppAttestationClientTest { private fun createAppAttestationClientForTest( restClient: RestClient = createSuccessfulRestClientForChallenge(), integrityManager: StandardIntegrityManager = createMockIntegrityManagerWithInertProviderTask(), + apiHostName: String? = TEST_API_HOST_NAME, + remoteAccessConsumerKeyProvider: RemoteAccessConsumerKeyProvider = RemoteAccessConsumerKeyProvider { TEST_REMOTE_ACCESS_CONSUMER_KEY }, ): AppAttestationClient = AppAttestationClient( - apiHostName = TEST_API_HOST_NAME, context = mockk(relaxed = true), deviceId = TEST_DEVICE_ID, googleCloudProjectId = TEST_GOOGLE_CLOUD_PROJECT_ID, integrityManager = integrityManager, - remoteAccessConsumerKey = TEST_REMOTE_ACCESS_CONSUMER_KEY, + remoteAccessConsumerKeyProvider = remoteAccessConsumerKeyProvider, restClient = restClient, - ) + ).also { it.apiHostName = apiHostName } private fun createSuccessfulRestClientForChallenge(): RestClient = createRestClientReturning( restResponse = createRestResponse(body = TEST_CHALLENGE_VALUE, success = true), diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index 62273eef17..ea1017343e 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -586,6 +586,13 @@ class LoginViewModelTest { redirectUri = debugRedirectUri, scopes = debugScopes, ) + coEvery { + sdkManagerMock.resolveOAuthConfigForLoginServer(any()) + } returns OAuthConfig( + consumerKey = appConfigConsumerKey, + redirectUri = appConfigRedirectUri, + scopes = listOf("api"), + ) // Verify the URL contains the app config values, not the debug override config values runBlocking { viewModel.generateAuthorizationUrl("test.salesforce.com", sdkManagerMock) } @@ -1089,6 +1096,36 @@ class LoginViewModelTest { } } + @Test + fun generateAuthorizationUrl_WhenFetchChallengeReturnsNull_OmitsAttestationParam() = runBlocking { + verifyAttestationOmittedWhenChallengeIsNull { viewModel, sdkManager -> + viewModel.generateAuthorizationUrl( + server = TEST_ATTESTATION_SERVER, + sdkManager = sdkManager, + ) + viewModel.loginUrl.value!! + } + } + + @Test + fun generateMigrationAuthorizationPath_WhenFetchChallengeReturnsNull_OmitsAttestationParam() = runBlocking { + verifyAttestationOmittedWhenChallengeIsNull { viewModel, sdkManager -> + val migrationConsumerKey = "migration_override_key_789" + val migrationRedirectUri = "migration://redirect" + val migrationScopes = listOf("api", "migration_scope") + + viewModel.generateMigrationAuthorizationPath( + server = TEST_ATTESTATION_SERVER, + migrationOAuthConfig = OAuthConfig( + migrationConsumerKey, + migrationRedirectUri, + migrationScopes, + ), + sdkManager = sdkManager, + ) + } + } + @Test fun generateAuthorizationUrl_WithEmptyJwtString_DoesNotActivateJwtFlow() = runBlocking { val sdkManagerMock = mockk(relaxed = true) @@ -1559,12 +1596,46 @@ class LoginViewModelTest { private fun createMockAppAttestationClient( attestation: String?, ): AppAttestationClient = mockk(relaxed = true).also { client -> - every { client.fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE + coEvery { client.fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE coEvery { client.createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) } returns attestation } + /** + * Creates a mock [AppAttestationClient] where + * [AppAttestationClient.fetchMobileAppAttestationChallenge] returns null, + * simulating the case where [AppAttestationClient.apiHostName] is null + * (Salesforce App Attestation is disabled for the current login server). + */ + private fun createMockAppAttestationClientWithNullChallenge(): AppAttestationClient = + mockk(relaxed = true).also { client -> + coEvery { client.fetchMobileAppAttestationChallenge() } returns null + } + + /** + * Helper to verify that attestation is omitted when fetchMobileAppAttestationChallenge returns null. + * + * @param urlGenerator Function that generates a URL string given a view model and SDK manager + */ + private suspend fun verifyAttestationOmittedWhenChallengeIsNull( + urlGenerator: suspend (LoginViewModel, SalesforceSDKManager) -> String + ) { + val appAttestationClient = createMockAppAttestationClientWithNullChallenge() + val sdkManagerMock = createSdkManagerMockForAttestation(appAttestationClient = appAttestationClient) + val freshViewModel = LoginViewModel(bootConfig) + + val url = urlGenerator(freshViewModel, sdkManagerMock) + + assertFalse( + "URL should NOT contain an attestation parameter when challenge fetch returns null, but was '$url'.", + url.contains(ATTESTATION_QUERY_PARAM_PREFIX), + ) + coVerify(exactly = 0) { + appAttestationClient.createAppAttestation(any()) + } + } + private fun generateExpectedAuthorizationUrl( server: String, codeChallenge: String, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt index 459f1552f8..74d8bbec67 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/NativeLoginManagerTest.kt @@ -22,6 +22,8 @@ import com.salesforce.androidsdk.security.BiometricAuthenticationManager.Compani import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -41,6 +43,9 @@ class NativeLoginManagerTest { private lateinit var mgr: NativeLoginManager private lateinit var bioAuthManager: BiometricAuthenticationManager + /** Retained before any mocking so that tearDown can clean up regardless of mock state. */ + private val realUserAccountManager = SalesforceSDKManager.getInstance().userAccountManager + @Before fun setUp() { mgr = NativeLoginManager("clientId", "redirect", "loginUrl") @@ -48,9 +53,7 @@ class NativeLoginManagerTest { @After fun tearDown() { - SalesforceSDKManager.getInstance().userAccountManager - .signoutCurrentUser(null, true, OAuth2.LogoutReason.USER_LOGOUT) - SalesforceSDKManager.getInstance().appAttestationClient = null + realUserAccountManager.signoutCurrentUser(null, true, OAuth2.LogoutReason.USER_LOGOUT) unmockkAll() } @@ -316,15 +319,7 @@ class NativeLoginManagerTest { mgr.login(TEST_USERNAME, TEST_PASSWORD) advanceUntilIdle() - verify(exactly = 1) { - restClient.sendAsync(match { - val buffer = okio.Buffer() - it.requestBody.writeTo(buffer) - val bodyString = buffer.readUtf8() - it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" && - bodyString.contains("attestation=$TEST_APP_ATTESTATION") - }, any()) - } + verifyLoginRequestAttestation(restClient, expectedAttestationValue = TEST_APP_ATTESTATION) } /** @@ -346,15 +341,7 @@ class NativeLoginManagerTest { mgr.login(TEST_USERNAME, TEST_PASSWORD) advanceUntilIdle() - verify(exactly = 1) { - restClient.sendAsync(match { - val buffer = okio.Buffer() - it.requestBody.writeTo(buffer) - val bodyString = buffer.readUtf8() - it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" && - !bodyString.contains("attestation=") - }, any()) - } + verifyLoginRequestAttestation(restClient, expectedAttestationValue = null) } /** @@ -373,27 +360,85 @@ class NativeLoginManagerTest { mgr.login(TEST_USERNAME, TEST_PASSWORD) advanceUntilIdle() + verifyLoginRequestAttestation(restClient, expectedAttestationValue = null) + } + + /** + * Tests that native login does not include app attestation during login + * when the app attestation client is set but + * [AppAttestationClient.fetchMobileAppAttestationChallenge] returns null + * (for example, because [AppAttestationClient.apiHostName] is null, meaning + * Salesforce App Attestation is disabled for the current login server). + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun nativeLoginManager_login_doesNotCollectAppAttestationWhenFetchChallengeReturnsNull() = runTest { + + val mockAppAttestationClient = mockk(relaxed = true).apply { + coEvery { fetchMobileAppAttestationChallenge() } returns null + } + mockkObject(SalesforceSDKManager) + val spySdkManager = spyk(SalesforceSDKManager.getInstance()) + every { SalesforceSDKManager.getInstance() } returns spySdkManager + every { spySdkManager.appAttestationClient } returns mockAppAttestationClient + + val restClient = createRestClientStubbingFailedLoginResponse() + mgr = createNativeLoginManagerForTest(restClient = restClient) + + mgr.login(TEST_USERNAME, TEST_PASSWORD) + advanceUntilIdle() + + verifyLoginRequestAttestation(restClient, expectedAttestationValue = null) + } + + // region Helpers used by attestation tests + + /** + * Verifies that the REST client received a login request with the expected + * attestation parameter state. + * + * @param restClient The REST client mock to verify + * @param expectedAttestationValue The expected attestation value if it should be included, + * or null if the attestation parameter should be excluded + */ + private fun verifyLoginRequestAttestation( + restClient: RestClient, + expectedAttestationValue: String? + ) { verify(exactly = 1) { restClient.sendAsync(match { val buffer = okio.Buffer() it.requestBody.writeTo(buffer) val bodyString = buffer.readUtf8() - it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" && - !bodyString.contains("attestation=") + val pathMatches = it.path == "$TEST_LOGIN_URL$OAUTH_AUTH_PATH" + val attestationMatches = if (expectedAttestationValue != null) { + bodyString.contains("attestation=$expectedAttestationValue") + } else { + !bodyString.contains("attestation=") + } + pathMatches && attestationMatches }, any()) } } - // region Helpers used by attestation tests - + /** + * Installs a spy over the real [SalesforceSDKManager] singleton so that + * only [SalesforceSDKManager.appAttestationClient] is overridden. All + * other real behaviour (e.g. the real [android.content.Context] and + * [com.salesforce.androidsdk.analytics.logger.SalesforceLogger]) is + * preserved, preventing logger-related crashes during login. + */ private fun installAppAttestationClient(attestation: String?) { - val appAttestationClient = mockk(relaxed = true).apply { - every { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE + val mockAppAttestationClient = mockk(relaxed = true).apply { + coEvery { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE coEvery { createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) } returns attestation } - SalesforceSDKManager.getInstance().appAttestationClient = appAttestationClient + mockkObject(SalesforceSDKManager) + val spySdkManager = spyk(SalesforceSDKManager.getInstance()) + every { SalesforceSDKManager.getInstance() } returns spySdkManager + every { spySdkManager.appAttestationClient } returns mockAppAttestationClient } private fun createRestClientStubbingFailedLoginResponse(): RestClient { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt index f890af29e0..e7c92c00fc 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2MockTests.kt @@ -66,7 +66,7 @@ class OAuth2MockTests { @Test fun oauth2_makeTokenEndpointRequest_includesAttestationParameterWhenNotNull() { val appAttestationClient = mockk(relaxed = true) { - every { fetchMobileAppAttestationChallenge() } returns "__TEST_CHALLENGE_VALUE__" + every { fetchMobileAppAttestationChallengeBlocking() } returns "__TEST_CHALLENGE_VALUE__" every { createAppAttestationBlocking("__TEST_CHALLENGE_VALUE__") } returns "__ATTESTATION_TOKEN__" } val salesforceSdkManager = mockk(relaxed = true) { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt index b5075b2e4d..c712cf8805 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelperTest.kt @@ -37,7 +37,6 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -65,8 +64,6 @@ class IDPAuthCodeHelperTest { val result = idpAuthCodeHelper.getAuthorizationPathForSP() - advanceUntilIdle() - val nonNullResult = requireNotNull(result) { "Result should be non-null for a valid login server." } @@ -101,8 +98,6 @@ class IDPAuthCodeHelperTest { val result = idpAuthCodeHelper.getAuthorizationPathForSP() - advanceUntilIdle() - val nonNullResult = requireNotNull(result) { "Result should be non-null for a valid login server." } @@ -121,8 +116,6 @@ class IDPAuthCodeHelperTest { val result = idpAuthCodeHelper.getAuthorizationPathForSP() - advanceUntilIdle() - val nonNullResult = requireNotNull(result) { "Result should be non-null for a valid login server." } @@ -132,6 +125,27 @@ class IDPAuthCodeHelperTest { ) } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun idpAuthCodeHelper_getAuthorizationPathForSP_whenFetchChallengeReturnsNull_excludesAttestationFromQuery() = runTest { + + // Simulate apiHostName being null (App Attestation disabled for the current login server). + val appAttestationClient = mockk(relaxed = true).apply { + coEvery { fetchMobileAppAttestationChallenge() } returns null + } + val idpAuthCodeHelper = createIdpAuthCodeHelper(appAttestationClient = appAttestationClient) + + val result = idpAuthCodeHelper.getAuthorizationPathForSP() + + val nonNullResult = requireNotNull(result) { + "Result should be non-null for a valid login server." + } + assertFalse( + "Result should NOT contain an attestation parameter when challenge fetch returns null, but was '$nonNullResult'.", + nonNullResult.contains("attestation="), + ) + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun idpAuthCodeHelper_getAuthorizationPathForSP_whenAuthorizationUrlIsNull_returnsNull() = runTest { @@ -141,8 +155,6 @@ class IDPAuthCodeHelperTest { val result = idpAuthCodeHelper.getAuthorizationPathForSP() - advanceUntilIdle() - assertNull("Result should be null when OAuth2.getAuthorizationUrl returns null.", result) } @@ -155,8 +167,6 @@ class IDPAuthCodeHelperTest { val result = idpAuthCodeHelper.getAuthorizationPathForSP() - advanceUntilIdle() - assertEquals(OAUTH_AUTHORIZE_PATH, result) } @@ -176,7 +186,7 @@ class IDPAuthCodeHelperTest { private fun createMockAttestationClient(attestation: String?): AppAttestationClient = mockk(relaxed = true).apply { - every { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE + coEvery { fetchMobileAppAttestationChallenge() } returns TEST_CHALLENGE_VALUE coEvery { createAppAttestation(appAttestationChallenge = TEST_CHALLENGE_VALUE) } returns attestation diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt index 13a4ababd9..c37600f18a 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityScenarioTest.kt @@ -29,7 +29,6 @@ package com.salesforce.androidsdk.ui import android.content.Intent import android.net.Uri.parse import android.webkit.WebView -import androidx.activity.result.ActivityResultLauncher import androidx.core.net.toUri import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED @@ -42,9 +41,6 @@ import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.ui.LoginActivity.Companion.EXTRA_KEY_LOGIN_HINT import com.salesforce.androidsdk.ui.LoginActivity.Companion.EXTRA_KEY_LOGIN_HOST -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/util/AuthConfigUtilTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/util/AuthConfigUtilTest.java index 0a5e59d590..28729e427e 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/util/AuthConfigUtilTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/util/AuthConfigUtilTest.java @@ -34,14 +34,17 @@ import androidx.core.content.ContextCompat; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; + import com.salesforce.androidsdk.app.SalesforceSDKManager; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.CompletableFuture; + import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + /** * Tests for AuthConfigUtil. * diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt index 0766c2cdf1..8423c78b8d 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt @@ -29,10 +29,10 @@ package com.salesforce.samples.authflowtester import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest -import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT -import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET import org.junit.Test import org.junit.runner.RunWith diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt index 2753224fb8..01a041e35e 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt @@ -30,20 +30,20 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_JWT +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE -import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT -import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE -import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_JWT +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig import com.salesforce.samples.authflowtester.testUtility.ScopeSelection import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL -import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.EMPTY -import org.junit.Assert.assertNotEquals +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Test import org.junit.runner.RunWith