diff --git a/libs/SalesforceSDK/AndroidManifest.xml b/libs/SalesforceSDK/AndroidManifest.xml index 976c09369a..d544868de7 100644 --- a/libs/SalesforceSDK/AndroidManifest.xml +++ b/libs/SalesforceSDK/AndroidManifest.xml @@ -69,23 +69,6 @@ android:theme="@style/SalesforceSDK" android:exported="true" /> - - - - - - - - - + val mainActivityClass: Class = mainActivity /** * Null or an authenticated Activity for private use when developer support @@ -234,6 +232,16 @@ open class SalesforceSDKManager protected constructor( */ var loginViewModelFactory = LoginViewModel.Factory + /** + * Asynchronously retrieves the app config for the specified login host. If not set or null is + * returned the values found in the BootConfig file will be used for all servers. + */ + var appConfigForLoginHost: suspend (server: String) -> OAuthConfig? = { + OAuthConfig(getBootConfig(appContext)) + } + + internal var debugOverrideAppConfig: OAuthConfig? = null + /** The class for the account switcher activity */ var accountSwitcherActivityClass = AccountSwitcherActivity::class.java @@ -374,7 +382,6 @@ open class SalesforceSDKManager protected constructor( @set:Synchronized var useWebServerAuthentication = true - /** * Whether or not the app supports welcome discovery. This should only be * enabled if the connected app is supported. @@ -432,6 +439,7 @@ open class SalesforceSDKManager protected constructor( return _lightColorScheme ?: sfLightColors().also { _lightColorScheme = it } } + @Suppress("unused") fun setLightColorScheme(value: ColorScheme) { _lightColorScheme = value } @@ -447,6 +455,7 @@ open class SalesforceSDKManager protected constructor( return _darkColorScheme ?: sfDarkColors().also { _darkColorScheme = it } } + @Suppress("unused") fun setDarkColorScheme(value: ColorScheme) { _darkColorScheme = value } @@ -531,7 +540,6 @@ open class SalesforceSDKManager protected constructor( /** Initializer */ init { - mainActivityClass = mainActivity features = ConcurrentSkipListSet(CASE_INSENSITIVE_ORDER) /* @@ -615,7 +623,7 @@ open class SalesforceSDKManager protected constructor( communityUrl: String, reCaptchaSiteKeyId: String? = null, googleCloudProjectId: String? = null, - isReCaptchaEnterprise: Boolean = false + isReCaptchaEnterprise: Boolean = false, ): NativeLoginManagerInterface { registerUsedAppFeature(FEATURE_NATIVE_LOGIN) nativeLoginManager = NativeLoginManager( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index 24f28bb111..b5dcc3c7cc 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -111,7 +111,9 @@ internal suspend fun onAuthFlowComplete( handleScreenLockPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleScreenLockPolicy, handleBiometricAuthPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleBiometricAuthPolicy, handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit = ::handleDuplicateUserAccount, - ) { +) { + // Reset Dev Support LoginOptionsActivity override + SalesforceSDKManager.getInstance().debugOverrideAppConfig = null // Note: Can't use default parameter value for suspended function parameter fetchUserIdentity val actualFetchUserIdentity = fetchUserIdentity ?: ::fetchUserIdentity @@ -140,14 +142,7 @@ internal suspend fun onAuthFlowComplete( w(TAG, "Missing refresh token scope.") } - // Check that the tokenResponse.scope contains the identity scope before calling the identity service - val userIdentity = if (scopeParser.hasIdentityScope()) { - actualFetchUserIdentity(tokenResponse) - } else { - w(TAG, "Missing identity scope, skipping identity service call.") - null - } - + val userIdentity = actualFetchUserIdentity(tokenResponse) val mustBeManagedApp = userIdentity?.customPermissions?.optBoolean(MUST_BE_MANAGED_APP_PERM) ?: false if (mustBeManagedApp && !runtimeConfig.isManagedApp) { onAuthFlowError( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt new file mode 100644 index 0000000000..51ff03f7a0 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025-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.config + +data class OAuthConfig( + val consumerKey: String, + val redirectUri: String, + val scopes: List? = null, +) { + + internal constructor(bootConfig: BootConfig): this( + bootConfig.remoteAccessConsumerKey, + bootConfig.oauthRedirectURI, + scopes = bootConfig.oauthScopes?.ifEmpty { null }?.toList(), + ) + + // Used by LoginOptionsActivity + internal constructor(consumerKey: String, redirectUri: String, scopes: String): this( + consumerKey.trim(), + redirectUri.trim(), + scopes = with(scopes) { + if (isNullOrBlank()) return@with null + + return@with if (contains(",")) { + split(",") + } else { + split(" ") + }.map { it.trim() } + } + ) + + // Used by LoginOptionsActivity + internal val scopesString + get() = scopes?.joinToString(separator = " ") ?: "" +} \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt index 5580ca4bfe..47f8b56880 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt @@ -157,9 +157,9 @@ data class DevSupportInfo( "Consumer Key" to currentUser.clientId, "Scopes" to currentUser.scope, "Instance URL" to currentUser.instanceServer, - "Token Format" to currentUser.tokenFormat, + "Token Format" to (currentUser.tokenFormat?.ifBlank { "Opaque" } ?: "Opaque"), "Access Token Expiration" to accessTokenExpiration, - "Beacon Child Consumer Key" to currentUser.beaconChildConsumerKey, + "Beacon Child Consumer Key" to (currentUser.beaconChildConsumerKey ?: "None"), ) } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 560e2ffaec..58481962a5 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -983,7 +983,7 @@ open class LoginActivity : FragmentActivity() { ) = salesforceWelcomeDiscoveryHostAndPathUrl.buildUpon() .appendQueryParameter( SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID, - viewModel.bootConfig.remoteAccessConsumerKey + viewModel.oAuthConfig.redirectUri, ) .appendQueryParameter( SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION, @@ -1138,7 +1138,7 @@ open class LoginActivity : FragmentActivity() { } val formattedUrl = request.url.toString().replace("///", "/").lowercase() - val callbackUrl = viewModel.bootConfig.oauthRedirectURI.replace("///", "/").lowercase() + val callbackUrl = viewModel.oAuthConfig.redirectUri.replace("///", "/").lowercase() val authFlowFinished = formattedUrl.startsWith(callbackUrl) if (authFlowFinished) { diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index 551d8058ec..d6d118f0c0 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -2,6 +2,7 @@ package com.salesforce.androidsdk.ui import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement @@ -16,7 +17,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -47,8 +51,11 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.R.string.sf__server_url_save import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.ui.components.CORNER_RADIUS import com.salesforce.androidsdk.ui.components.PADDING_SIZE import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor @@ -104,11 +111,17 @@ class LoginOptionsActivity: ComponentActivity() { useWebServer, useHybridToken, supportWelcomeDiscovery, + SalesforceSDKManager.getInstance().debugOverrideAppConfig, ) } } } } + + override fun finish() { + super.finish() + SalesforceSDKManager.getInstance().loginDevMenuReload = true + } } @Composable @@ -129,10 +142,7 @@ fun OptionToggle( ) Switch( checked = checked, - onCheckedChange = { - optionData.value = it - SalesforceSDKManager.getInstance().loginDevMenuReload = true - }, + onCheckedChange = { optionData.value = it }, modifier = Modifier.semantics { this.contentDescription = contentDescription } @@ -141,18 +151,20 @@ fun OptionToggle( } @Composable -fun BootConfigView() { - var dynamicConsumerKey by remember { mutableStateOf("") } - var dynamicRedirectUri by remember { mutableStateOf("") } - var dynamicScopes by remember { mutableStateOf("") } +fun BootConfigView(config: OAuthConfig? = null) { + var overrideConsumerKey by remember { mutableStateOf(config?.consumerKey ?: "") } + var overrideRedirectUri by remember { mutableStateOf(config?.redirectUri ?: "") } + var overrideScopes by remember { mutableStateOf(config?.scopesString ?: "") } val consumerKeyFieldDesc = stringResource(R.string.sf__login_options_consumer_key_field_content_description) val redirectFieldDesc = stringResource(R.string.sf__login_options_redirect_uri_field_content_description) val scopesFieldDesc = stringResource(R.string.sf__login_options_scopes_field_content_description) + val validInput = overrideConsumerKey.isNotBlank() && overrideRedirectUri.isNotBlank() + val activity = LocalActivity.current Column { OutlinedTextField( - value = dynamicConsumerKey, - onValueChange = { dynamicConsumerKey = it }, + value = overrideConsumerKey, + onValueChange = { overrideConsumerKey = it }, label = { Text("Consumer Key") }, singleLine = true, modifier = Modifier @@ -173,8 +185,8 @@ fun BootConfigView() { ) OutlinedTextField( - value = dynamicRedirectUri, - onValueChange = { dynamicRedirectUri = it }, + value = overrideRedirectUri, + onValueChange = { overrideRedirectUri = it }, label = { Text("Redirect URI") }, singleLine = true, modifier = Modifier @@ -195,8 +207,8 @@ fun BootConfigView() { ) OutlinedTextField( - value = dynamicScopes, - onValueChange = { dynamicScopes = it }, + value = overrideScopes, + onValueChange = { overrideScopes = it }, label = { Text("Scopes") }, singleLine = true, modifier = Modifier @@ -215,6 +227,33 @@ fun BootConfigView() { cursorColor = colorScheme.tertiary, ), ) + + Button( + modifier = Modifier.padding(PADDING_SIZE.dp).fillMaxWidth(), + shape = RoundedCornerShape(CORNER_RADIUS.dp), + contentPadding = PaddingValues(PADDING_SIZE.dp), + colors = ButtonColors( + containerColor = colorScheme.tertiary, + contentColor = colorScheme.tertiary, + disabledContainerColor = colorScheme.surfaceVariant, + disabledContentColor = colorScheme.surfaceVariant, + ), + enabled = validInput, + onClick = { + SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig( + overrideConsumerKey, + overrideRedirectUri, + overrideScopes, + ) + activity?.finish() + }, + ) { + Text( + text = stringResource(sf__server_url_save), + fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium, + color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer, + ) + } } } @@ -245,9 +284,10 @@ fun LoginOptionsScreen( useWebServer: MutableLiveData, useHybridToken: MutableLiveData, supportWelcomeDiscovery: MutableLiveData, + overrideConfig: OAuthConfig?, bootConfig: BootConfig = BootConfig.getBootConfig(LocalContext.current), ) { - var useDynamicConfig by remember { mutableStateOf(false) } + var useDynamicConfig by remember { mutableStateOf(overrideConfig != null) } Column( modifier = Modifier @@ -296,13 +336,19 @@ fun LoginOptionsScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Use Dynamic Boot Config", + text = "Override Boot Config", modifier = Modifier.height( 50.dp).wrapContentHeight(align = Alignment.CenterVertically), ) val dynamicConfigToggleDesc = stringResource(R.string.sf__login_options_dynamic_config_toggle_content_description) Switch( checked = useDynamicConfig, - onCheckedChange = { useDynamicConfig = it }, + onCheckedChange = { + useDynamicConfig = it + // Reset the stored value on uncheck so it is not used. + if (!useDynamicConfig) { + SalesforceSDKManager.getInstance().debugOverrideAppConfig = null + } + }, modifier = Modifier.semantics { contentDescription = dynamicConfigToggleDesc } @@ -310,7 +356,7 @@ fun LoginOptionsScreen( } if (useDynamicConfig) { - BootConfigView() + BootConfigView(overrideConfig) } } } @@ -331,6 +377,19 @@ fun BootConfigViewPreview() { BootConfigView() } +@ExcludeFromJacocoGeneratedReport +@Preview +@Composable +fun BootConfigViewFilledPreview() { + val config = OAuthConfig( + stringResource(R.string.remoteAccessConsumerKey), + stringResource(R.string.oauthRedirectURI), + listOf("web", "api"), + ) + + BootConfigView(config) +} + @ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Composable @@ -343,6 +402,7 @@ fun LoginOptionsScreenPreview() { useWebServer = MutableLiveData(true), useHybridToken = MutableLiveData(false), supportWelcomeDiscovery = MutableLiveData(false), + overrideConfig = null, bootConfig = object : BootConfig() { override fun getRemoteAccessConsumerKey() = consumerKey override fun getOauthRedirectURI() = redirect diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index bc2b7be6c9..8248e54e26 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -26,6 +26,7 @@ */ package com.salesforce.androidsdk.ui +import android.annotation.SuppressLint import android.webkit.CookieManager import android.webkit.URLUtil import android.webkit.WebView @@ -42,6 +43,7 @@ import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.salesforce.androidsdk.R.string.oauth_display_type import com.salesforce.androidsdk.R.string.sf__login_with_biometric @@ -57,6 +59,7 @@ import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl import com.salesforce.androidsdk.auth.defaultBuildAccountName import com.salesforce.androidsdk.auth.onAuthFlowComplete import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getRandom128ByteKey import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK @@ -139,6 +142,8 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { var jwt: String? = null /** Connected App/External Client App client Id. */ + @Deprecated("Will be removed in Mobile SDK 14.0, please use " + + "SalesforceSDKManager.getInstance().appConfigForLoginHost.") protected open var clientId: String = bootConfig.remoteAccessConsumerKey /** Authorization Display Type used for login. */ @@ -206,22 +211,37 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { internal var frontdoorBridgeCodeVerifier: String? = null + // Dynamic OAuth Config - initialized with bootConfig, then updated asynchronously + internal var oAuthConfig = OAuthConfig(bootConfig) + private val consumerKey: String + get() = if (clientId != bootConfig.remoteAccessConsumerKey) { + clientId + } else { + oAuthConfig.consumerKey + } + init { // Update selectedServer when the LoginServerManager value changes selectedServer.addSource(SalesforceSDKManager.getInstance().loginServerManager.selectedServer) { newServer -> - val trimmedServer = newServer.url.run { trim { it <= ' ' } } - if (selectedServer.value == trimmedServer) { - reloadWebView() - } else { - selectedServer.value = trimmedServer + val trimmedServer = newServer?.url?.run { trim { it <= ' ' } } + trimmedServer?.let { nonNullServer -> + if (selectedServer.value == nonNullServer) { + reloadWebView() + } else { + selectedServer.value = nonNullServer + } } } // Update loginUrl when selectedServer updates so webview automatically reloads - loginUrl.addSource(selectedServer) { newServer -> - val isNewServer = loginUrl.value?.startsWith(newServer) != true - if (isNewServer && !isUsingFrontDoorBridge) { - loginUrl.value = getAuthorizationUrl(newServer) + loginUrl.addSource(selectedServer) { newServer: String? -> + if (!isUsingFrontDoorBridge && newServer != null) { + val isNewServer = loginUrl.value?.startsWith(newServer) != true + if (isNewServer) { + viewModelScope.launch { + loginUrl.value = getAuthorizationUrl(newServer) + } + } } } } @@ -229,12 +249,17 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { /** Reloads the WebView with a newly generated authorization URL. */ open fun reloadWebView() { if (!isUsingFrontDoorBridge) { - // The Web Server Flow code challenge makes the authorization url unique each time, - // which triggers recomposition. For User Agent Flow, change it to blank. - if (!SalesforceSDKManager.getInstance().useWebServerAuthentication) { - loginUrl.value = ABOUT_BLANK + selectedServer.value?.let { server -> + // The Web Server Flow code challenge makes the authorization url unique each time, + // which triggers recomposition. For User Agent Flow, change it to blank. + if (!SalesforceSDKManager.getInstance().useWebServerAuthentication) { + loginUrl.value = ABOUT_BLANK + } + + viewModelScope.launch { + loginUrl.value = getAuthorizationUrl(server) + } } - loginUrl.value = getAuthorizationUrl(selectedServer.value ?: return) } } @@ -287,6 +312,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { * Called when the webview portion of the User Agent flow or the code exchange * portion of the Web Server is finished to create and the user. */ + @SuppressLint("VisibleForTests") // onAuthFlowComplete cannot be otherwise internal. internal suspend fun onAuthFlowComplete( tr: TokenEndpointResponse, onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit, @@ -299,7 +325,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { onAuthFlowComplete( tokenResponse = tr, loginServer = selectedServer.value ?: "", // This will never actually be null. - consumerKey = clientId, + consumerKey = consumerKey, onAuthFlowError = onAuthFlowError, onAuthFlowSuccess = onAuthFlowSuccess, buildAccountName = ::buildAccountName, @@ -339,7 +365,19 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { }?.removeSuffix("/") } - private fun getAuthorizationUrl(server: String): String { + private suspend fun getAuthorizationUrl(server: String): String = withContext(IO) { + // Show loading indicator because appConfigForLoginHost could take a noticeable amount of time. + loading.value = true + + with(SalesforceSDKManager.getInstance()) { + // Check if the OAuth Config has been manually set by dev support LoginOptionsActivity. + oAuthConfig = if (isDebugBuild && debugOverrideAppConfig != null) { + debugOverrideAppConfig!! + } else { + appConfigForLoginHost(server) ?: OAuthConfig(bootConfig) + } + } + val jwtFlow = !jwt.isNullOrBlank() && !authCodeForJwtFlow.isNullOrBlank() val additionalParams = when { jwtFlow -> null @@ -353,16 +391,16 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { useWebServerFlow, SalesforceSDKManager.getInstance().useHybridAuthentication, URI(server), - clientId, - bootConfig.oauthRedirectURI, - bootConfig.oauthScopes, + consumerKey, + oAuthConfig.redirectUri, + oAuthConfig.scopes?.toTypedArray(), loginHint, authorizationDisplayType, codeChallenge, additionalParams ) - return when { + return@withContext when { jwtFlow -> getFrontdoorUrl( authorizationUrl, authCodeForJwtFlow, @@ -386,10 +424,10 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { val tokenResponse = exchangeCode( HttpAccess.DEFAULT, URI.create(server), - clientId, + consumerKey, code, verifier, - bootConfig.oauthRedirectURI, + oAuthConfig.redirectUri, ) onAuthFlowComplete(tokenResponse, onAuthFlowError, onAuthFlowSuccess) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt index 227cdb9d3d..b390d251fc 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt @@ -229,20 +229,23 @@ internal fun LoginView( contentWindowInsets = WindowInsets.safeDrawing, topBar = topAppBar, ) { innerPadding -> - if (loading) { - loadingIndicator() + Box(modifier = Modifier.fillMaxSize()) { + // Load the WebView as a composable + AndroidView( + modifier = Modifier + .background(dynamicBackgroundColor.value) + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .applyImePaddingConditionally() + .graphicsLayer(alpha = alpha), + factory = { webView }, + update = { it.loadUrl(loginUrl.value ?: "") }, + ) + + if (loading) { + loadingIndicator() + } } - // Load the WebView as a composable - AndroidView( - modifier = Modifier - .background(dynamicBackgroundColor.value) - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .applyImePaddingConditionally() - .graphicsLayer(alpha = alpha), - factory = { webView }, - update = { it.loadUrl(loginUrl.value ?: "") }, - ) if (showServerPicker.value) { PickerBottomSheet(PickerStyle.LoginServerPicker) diff --git a/libs/SalesforceSDK/src/debug/AndroidManifest.xml b/libs/SalesforceSDK/src/debug/AndroidManifest.xml index b56e6a35b4..2a96ba4339 100644 --- a/libs/SalesforceSDK/src/debug/AndroidManifest.xml +++ b/libs/SalesforceSDK/src/debug/AndroidManifest.xml @@ -1,5 +1,24 @@ - + + + + + + + + + + + + diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerAuthConfigTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerAuthConfigTest.kt new file mode 100644 index 0000000000..f13dc940b0 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerAuthConfigTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025-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 +import com.salesforce.androidsdk.MainActivity +import com.salesforce.androidsdk.config.BootConfig +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.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for SalesforceSDKManager's appConfigForLoginHost and debugOverrideAppConfig functionality. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class SalesforceSDKManagerAuthConfigTest { + + private lateinit var sdkManager: TestSalesforceSDKManager + private lateinit var targetContext: Context + + @Before + fun setUp() { + targetContext = InstrumentationRegistry.getInstrumentation().targetContext + TestSalesforceSDKManager.init(targetContext, MainActivity::class.java) + sdkManager = TestSalesforceSDKManager.getInstance() as TestSalesforceSDKManager + sdkManager.isTestRun = true + } + + @After + fun tearDown() { + // Reset debug override + sdkManager.debugOverrideAppConfig = null + TestSalesforceSDKManager.resetInstance() + } + + @Test + fun testAppConfigForLoginHostDefaultBehavior() = runBlocking { + val bootConfig = BootConfig.getBootConfig(InstrumentationRegistry.getInstrumentation().targetContext) + // Default implementation should return config from BootConfig + val config = sdkManager.appConfigForLoginHost("https://login.salesforce.com")!! + + assertNotNull("Config should not be null", config) + assertEquals( + "Consumer key should match BootConfig", + bootConfig.remoteAccessConsumerKey, + config.consumerKey + ) + assertEquals( + "Redirect URI should match BootConfig", + bootConfig.oauthRedirectURI, + config.redirectUri + ) + } + + @Test + fun testAppConfigForLoginHostWithDifferentServers() = runBlocking { + // Default implementation ignores server parameter and returns same config + val loginConfig = sdkManager.appConfigForLoginHost("https://login.salesforce.com") + val testConfig = sdkManager.appConfigForLoginHost("https://test.salesforce.com") + val customConfig = sdkManager.appConfigForLoginHost("https://custom.my.salesforce.com") + + assertEquals("All servers should return same config by default", loginConfig, testConfig) + assertEquals("All servers should return same config by default", loginConfig, customConfig) + } + + @Test + fun testAppConfigForLoginHostCanBeReassigned() = runBlocking { + // First implementation + sdkManager.appConfigForLoginHost = { _ -> + OAuthConfig("key1", "uri1", listOf("api")) + } + val config1 = sdkManager.appConfigForLoginHost("https://test.com")!! + assertEquals("key1", config1.consumerKey) + + // Reassign to different implementation + sdkManager.appConfigForLoginHost = { _ -> + OAuthConfig("key2", "uri2", listOf("web")) + } + val config2 = sdkManager.appConfigForLoginHost("https://test.com")!! + assertEquals("key2", config2.consumerKey) + } + + @Test + fun testDebugOverrideAppConfigDefaultIsNull() { + assertNull("debugOverrideAppConfig should be null by default", sdkManager.debugOverrideAppConfig) + } + + @Test + fun testDebugOverrideAppConfigCanBeSet() { + val overrideConfig = OAuthConfig( + consumerKey = "override_key", + redirectUri = "override://callback", + scopes = listOf("api", "web", "refresh_token") + ) + + sdkManager.debugOverrideAppConfig = overrideConfig + + assertNotNull("debugOverrideAppConfig should not be null", sdkManager.debugOverrideAppConfig) + assertEquals("override_key", sdkManager.debugOverrideAppConfig?.consumerKey) + assertEquals("override://callback", sdkManager.debugOverrideAppConfig?.redirectUri) + assertEquals(listOf("api", "web", "refresh_token"), sdkManager.debugOverrideAppConfig?.scopes) + } + + @Test + fun testDebugOverrideAppConfigCanBeCleared() { + val overrideConfig = OAuthConfig( + consumerKey = "override_key", + redirectUri = "override://callback", + scopes = listOf("api") + ) + + sdkManager.debugOverrideAppConfig = overrideConfig + assertNotNull("debugOverrideAppConfig should be set", sdkManager.debugOverrideAppConfig) + + sdkManager.debugOverrideAppConfig = null + assertNull("debugOverrideAppConfig should be null after clearing", sdkManager.debugOverrideAppConfig) + } + + /** + * Test version of SalesforceSDKManager that doesn't interfere with other tests. + */ + private class TestSalesforceSDKManager( + context: Context, + mainActivity: Class, + loginActivity: Class, + ) : SalesforceSDKManager(context, mainActivity, loginActivity) { + + 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/auth/AuthenticationUtilitiesTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt index 55bb12ae4e..61bed72135 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -177,6 +177,9 @@ class AuthenticationUtilitiesTest { val tokenResponseWithoutIdScope = createTokenEndpointResponse( scope = "refresh_token" // Missing id scope ) + + // Mock fetchUserIdentity to return null (simulating no id scope) + coEvery { fetchUserIdentity.invoke(any()) } returns null // Create the expected UserAccount object without IdServiceResponse population val expectedAccount = UserAccountBuilder.getInstance() @@ -204,8 +207,8 @@ class AuthenticationUtilitiesTest { verify { handleScreenLockPolicy.invoke(null, expectedAccount) } verify { handleBiometricAuthPolicy.invoke(null, expectedAccount) } - // Verify that fetchUserIdentity was never called since there's no id scope - coVerify(exactly = 0) { fetchUserIdentity.invoke(any()) } + // Verify that fetchUserIdentity was called but returned null + coVerify(exactly = 1) { fetchUserIdentity.invoke(tokenResponseWithoutIdScope) } } private suspend fun callOnAuthFlowComplete(customTokenResponse: OAuth2.TokenEndpointResponse? = null) { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt new file mode 100644 index 0000000000..0e8d772a06 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -0,0 +1,553 @@ +/* + * Copyright (c) 2025-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.auth + +import android.webkit.CookieManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse +import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.ui.LoginViewModel +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.net.URI + +/** + * Tests for LoginViewModel that require mocking. + * These tests are separated from LoginViewModelTest to isolate mock usage. + */ +@RunWith(AndroidJUnit4::class) +class LoginViewModelMockTest { + @get:Rule + val instantExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule() + + private val context = InstrumentationRegistry.getInstrumentation().context + private val bootConfig = BootConfig.getBootConfig(context) + private lateinit var viewModel: LoginViewModel + private lateinit var mockCookieManager: CookieManager + + @Before + fun setup() { + // Mock CookieManager to avoid WebView dependencies + mockkStatic(CookieManager::class) + mockCookieManager = mockk(relaxed = true) + every { CookieManager.getInstance() } returns mockCookieManager + + // Create view model after mocking + viewModel = LoginViewModel(bootConfig) + + // This is required for the LiveData to actually update during the test + viewModel.selectedServer.observeForever { } + viewModel.loginUrl.observeForever { } + + // Give the LiveData sources time to propagate + Thread.sleep(100) + } + + @After + fun teardown() { + SalesforceSDKManager.getInstance().loginServerManager.reset() + SalesforceSDKManager.getInstance().debugOverrideAppConfig = null + unmockkAll() + } + + @Test + fun onAuthFlowComplete_CallsAuthenticationUtilities_WithCorrectParameters() = runBlocking { + // Mock the AuthenticationUtilities.onAuthFlowComplete function + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + + // Mock the function to do nothing (just capture parameters) + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + // Create test data + val testServer = "https://test.salesforce.com" + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Set up the view model state + viewModel.selectedServer.value = testServer + Thread.sleep(100) + + // Call the method under test + viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) + + // Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters + coVerify { + onAuthFlowComplete( + tokenResponse = eq(mockTokenResponse), + loginServer = eq(testServer), + consumerKey = eq(bootConfig.remoteAccessConsumerKey), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } + } + + @Test + fun onAuthFlowComplete_CallsAuthenticationUtilitiesSuccessfully() = runBlocking { + // Mock the AuthenticationUtilities.onAuthFlowComplete function + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Set up the view model state + viewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + // Call the method under test + viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) + + // Verify onAuthFlowComplete was called, which confirms the method executed successfully + coVerify { + mockCookieManager.removeAllCookies(null) + onAuthFlowComplete( + tokenResponse = mockTokenResponse, + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } + } + + @Test + fun onAuthFlowComplete_ResetsAuthCodeForJwtFlow() = runBlocking { + // Mock the AuthenticationUtilities.onAuthFlowComplete function + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Set up the view model state with JWT flow + viewModel.selectedServer.value = "https://test.salesforce.com" + viewModel.authCodeForJwtFlow = "test_jwt_auth_code" + Thread.sleep(100) + + // Verify authCodeForJwtFlow is set + assertNotNull("authCodeForJwtFlow should be set before call", viewModel.authCodeForJwtFlow) + + // Call the method under test + viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) + + // Verify authCodeForJwtFlow is reset to null + assertNull("authCodeForJwtFlow should be null after call", viewModel.authCodeForJwtFlow) + } + + @Test + fun onAuthFlowComplete_UsesEmptyString_WhenSelectedServerIsNull() = runBlocking { + // Mock the AuthenticationUtilities.onAuthFlowComplete function + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Set selectedServer to null + viewModel.selectedServer.value = null + Thread.sleep(100) + + // Call the method under test + viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) + + // Verify empty string is used when selectedServer is null + coVerify { + onAuthFlowComplete( + tokenResponse = eq(mockTokenResponse), + loginServer = eq(""), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } + } + + @Test + fun doCodeExchange_CallsExchangeCode_WithCorrectParameters() = runBlocking { + // Mock OAuth2.exchangeCode + mockkStatic(OAuth2::class) + + val testServer = "https://test.salesforce.com" + val testCode = "test_auth_code_123" + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Mock AuthenticationUtilities.onAuthFlowComplete to prevent actual execution + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + // Mock exchangeCode to return our mock token response + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } returns mockTokenResponse + + // Set up the view model state + viewModel.selectedServer.value = testServer + Thread.sleep(100) + + // Call the method under test via onWebServerFlowComplete + viewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify exchangeCode was called with correct parameters + verify { + OAuth2.exchangeCode( + HttpAccess.DEFAULT, + URI.create(testServer), + bootConfig.remoteAccessConsumerKey, + testCode, + viewModel.codeVerifier, + bootConfig.oauthRedirectURI, + ) + } + } + + @Test + fun doCodeExchange_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking { + // Mock OAuth2.exchangeCode + mockkStatic(OAuth2::class) + + val frontDoorServer = "https://frontdoor.salesforce.com" + val frontDoorUrl = "$frontDoorServer/frontdoor.jsp?sid=test_session" + val frontDoorVerifier = "frontdoor_verifier_789" + val testCode = "test_auth_code_123" + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Mock AuthenticationUtilities.onAuthFlowComplete + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + // Mock exchangeCode + every { + OAuth2.exchangeCode( + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns mockTokenResponse + + // Set up front door bridge + viewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier) + Thread.sleep(100) + + // Call the method under test + viewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify exchangeCode uses frontdoor server and verifier + verify { + OAuth2.exchangeCode( + HttpAccess.DEFAULT, + URI.create(frontDoorServer), + bootConfig.remoteAccessConsumerKey, + testCode, + frontDoorVerifier, + bootConfig.oauthRedirectURI, + ) + } + } + + @Test + fun doCodeExchange_WithNullCode_PassesNullToExchangeCode() = runBlocking { + // Mock OAuth2.exchangeCode + mockkStatic(OAuth2::class) + + val testServer = "https://test.salesforce.com" + val mockTokenResponse = mockk(relaxed = true) + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Mock AuthenticationUtilities.onAuthFlowComplete + mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") + coEvery { + onAuthFlowComplete( + tokenResponse = any(), + loginServer = any(), + consumerKey = any(), + onAuthFlowError = any(), + onAuthFlowSuccess = any(), + buildAccountName = any(), + nativeLogin = any(), + context = any(), + userAccountManager = any(), + blockIntegrationUser = any(), + runtimeConfig = any(), + updateLoggingPrefs = any(), + fetchUserIdentity = any(), + startMainActivity = any(), + setAdministratorPreferences = any(), + addAccount = any(), + handleScreenLockPolicy = any(), + handleBiometricAuthPolicy = any(), + handleDuplicateUserAccount = any(), + ) + } returns Unit + + // Mock exchangeCode + every { + OAuth2.exchangeCode( + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns mockTokenResponse + + // Set up the view model state + viewModel.selectedServer.value = testServer + Thread.sleep(100) + + // Call with null code + viewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify exchangeCode was called with null code + verify { + OAuth2.exchangeCode( + HttpAccess.DEFAULT, + URI.create(testServer), + bootConfig.remoteAccessConsumerKey, + null, + viewModel.codeVerifier, + bootConfig.oauthRedirectURI, + ) + } + } +} 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 6305a8f7f9..19c1209bff 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -34,6 +34,7 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.LoginServerManager.LoginServer +import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK import com.salesforce.androidsdk.ui.LoginViewModel @@ -69,12 +70,15 @@ class LoginViewModelTest { // because it isn't actually being observed since there is no lifecycle. viewModel.selectedServer.observeForever { } viewModel.loginUrl.observeForever { } - + + // Give the LiveData sources time to propagate through the MediatorLiveData + Thread.sleep(100) } @After fun teardown() { SalesforceSDKManager.getInstance().loginServerManager.reset() + SalesforceSDKManager.getInstance().debugOverrideAppConfig = null } // Google's recommended naming scheme for view model test is "thingUnderTest_TriggerOfTest_ResultOfTest" @@ -117,11 +121,19 @@ class LoginViewModelTest { @Test fun loginUrl_UpdatesOn_selectedServerChange() { + // Wait for initial values to be set + assertNotNull(viewModel.selectedServer.value) + assertNotNull(viewModel.loginUrl.value) + assertNotEquals(FAKE_SERVER_URL, viewModel.selectedServer.value) assertTrue(viewModel.loginUrl.value!!.startsWith(viewModel.selectedServer.value!!)) assertFalse(viewModel.loginUrl.value!!.startsWith(FAKE_SERVER_URL)) viewModel.selectedServer.value = FAKE_SERVER_URL + + // Wait for loginUrl to update after selectedServer change (async coroutine) + Thread.sleep(200) + assertNotNull(viewModel.loginUrl.value) assertTrue(viewModel.loginUrl.value!!.startsWith(FAKE_SERVER_URL)) } @@ -133,6 +145,8 @@ class LoginViewModelTest { assertEquals(originalAuthUrl, viewModel.loginUrl.value) viewModel.selectedServer.value = FAKE_SERVER_URL + // Wait for async update + Thread.sleep(200) val newCodeChallenge = getSHA256Hash(viewModel.codeVerifier) assertNotEquals(originalCodeChallenge, newCodeChallenge) val newAuthUrl = generateExpectedAuthorizationUrl(FAKE_SERVER_URL, newCodeChallenge) @@ -145,6 +159,8 @@ class LoginViewModelTest { assertTrue(viewModel.loginUrl.value!!.contains(originalCodeChallenge)) viewModel.reloadWebView() + // Wait for async update + Thread.sleep(200) val newCodeChallenge = getSHA256Hash(viewModel.codeVerifier) assertNotNull(newCodeChallenge) assertNotEquals(originalCodeChallenge, newCodeChallenge) @@ -161,6 +177,8 @@ class LoginViewModelTest { viewModel.jwt = FAKE_JWT viewModel.authCodeForJwtFlow = FAKE_JWT_FLOW_AUTH viewModel.reloadWebView() + // Wait for async update + Thread.sleep(200) assertNotEquals(expectedUrl, viewModel.loginUrl.value) codeChallenge = getSHA256Hash(viewModel.codeVerifier) @@ -197,6 +215,342 @@ class LoginViewModelTest { assertEquals(unchangedUrl, viewModel.getValidServerUrl(endingSlash)) } + @Test + fun getAuthorizationUrl_UsesDebugOverrideAppConfig_WhenSet() { + // Set custom OAuth config via debugOverrideAppConfig + val customConsumerKey = "custom_consumer_key_123" + val customRedirectUri = "custom://redirect" + val customScopes = listOf("api", "web", "custom_scope") + SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig( + consumerKey = customConsumerKey, + redirectUri = customRedirectUri, + scopes = customScopes + ) + + // Trigger URL generation + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify the URL contains the custom consumer key and redirect URI + val loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain custom consumer key", loginUrl.contains(customConsumerKey)) + assertTrue("URL should contain custom redirect URI", loginUrl.contains("redirect_uri=custom://redirect")) + assertTrue("URL should contain custom scope", loginUrl.contains("custom_scope")) + } + + @Test + fun getAuthorizationUrl_UsesBootConfig_WhenDebugOverrideAppConfigIsNull() { + // Ensure debugOverrideAppConfig is null + SalesforceSDKManager.getInstance().debugOverrideAppConfig = null + + // Trigger URL generation + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify the URL contains the boot config values + val loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain boot config consumer key", + loginUrl.contains(bootConfig.remoteAccessConsumerKey)) + assertTrue("URL should contain boot config redirect URI", + loginUrl.contains("redirect_uri=${bootConfig.oauthRedirectURI}")) + } + + @Test + fun getAuthorizationUrl_UsesAppConfigForLoginHost_WhenDebugOverrideIsNull() { + val sdkManager = SalesforceSDKManager.getInstance() + val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost + + try { + // Ensure debugOverrideAppConfig is null + sdkManager.debugOverrideAppConfig = null + + // Set custom appConfigForLoginHost + val customConsumerKey = "app_config_consumer_key_456" + val customRedirectUri = "appconfig://redirect" + val customScopes = listOf("api", "refresh_token", "app_config_scope") + sdkManager.appConfigForLoginHost = { _ -> + OAuthConfig( + consumerKey = customConsumerKey, + redirectUri = customRedirectUri, + scopes = customScopes, + ) + } + + // Trigger URL generation + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify the URL contains the custom app config values + val loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain app config consumer key", loginUrl.contains(customConsumerKey)) + assertTrue("URL should contain app config redirect URI", + loginUrl.contains("redirect_uri=appconfig://redirect")) + assertTrue("URL should contain app config scope", loginUrl.contains("app_config_scope")) + } finally { + sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost + } + } + + @Test + fun getAuthorizationUrl_PrefersDebugOverrideAppConfig_OverAppConfigForLoginHost() { + val sdkManager = SalesforceSDKManager.getInstance() + val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost + + try { + // Set both debugOverrideAppConfig and appConfigForLoginHost + val debugConsumerKey = "debug_override_key_789" + val debugRedirectUri = "debug://redirect" + val debugScopes = listOf("api", "debug_scope") + sdkManager.debugOverrideAppConfig = OAuthConfig( + consumerKey = debugConsumerKey, + redirectUri = debugRedirectUri, + scopes = debugScopes, + ) + + val appConfigConsumerKey = "app_config_key_should_not_be_used" + val appConfigRedirectUri = "appconfig://should_not_be_used" + sdkManager.appConfigForLoginHost = { _ -> + OAuthConfig( + consumerKey = appConfigConsumerKey, + redirectUri = appConfigRedirectUri, + scopes = listOf("api"), + ) + } + + // Trigger URL generation + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify the URL contains the debug override values, not app config values + val loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain debug override consumer key", + loginUrl.contains(debugConsumerKey)) + assertTrue("URL should contain debug override redirect URI", + loginUrl.contains("redirect_uri=debug://redirect")) + assertTrue("URL should contain debug scope", loginUrl.contains("debug_scope")) + + // Verify app config values are NOT in the URL + assertFalse("URL should NOT contain app config consumer key", + loginUrl.contains(appConfigConsumerKey)) + assertFalse("URL should NOT contain app config redirect URI", + loginUrl.contains("should_not_be_used")) + } finally { + sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost + } + } + + @Test + fun getAuthorizationUrl_UsesServerSpecificConfig_FromAppConfigForLoginHost() { + val sdkManager = SalesforceSDKManager.getInstance() + val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost + + try { + // Ensure debugOverrideAppConfig is null + sdkManager.debugOverrideAppConfig = null + + // Set appConfigForLoginHost that returns different configs based on server + sdkManager.appConfigForLoginHost = { server -> + when { + server.contains("test.salesforce.com") -> OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://redirect", + scopes = listOf("api", "test_scope"), + ) + server.contains("login.salesforce.com") -> OAuthConfig( + consumerKey = "prod_consumer_key", + redirectUri = "prod://redirect", + scopes = listOf("api", "prod_scope"), + ) + else -> OAuthConfig(bootConfig) + } + } + + // Test with test server + viewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(200) + var loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain test consumer key. URL: $loginUrl", + loginUrl.contains("test_consumer_key")) + assertTrue("URL should contain test redirect URI. URL: $loginUrl", + loginUrl.contains("redirect_uri=test://redirect")) + assertTrue("URL should contain test scope. URL: $loginUrl", + loginUrl.contains("test_scope")) + + // Test with production server + viewModel.selectedServer.value = "https://login.salesforce.com" + Thread.sleep(200) + loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain prod consumer key. URL: $loginUrl", + loginUrl.contains("prod_consumer_key")) + assertTrue("URL should contain prod redirect URI. URL: $loginUrl", + loginUrl.contains("redirect_uri=prod://redirect")) + assertTrue("URL should contain prod scope. URL: $loginUrl", + loginUrl.contains("prod_scope")) + } finally { + sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost + } + } + + @Test + fun getAuthorizationUrl_HandlesNullScopes_InOAuthConfig() { + // Set OAuth config with null scopes + val customConsumerKey = "no_scopes_consumer_key" + val customRedirectUri = "noscopes://redirect" + SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig( + consumerKey = customConsumerKey, + redirectUri = customRedirectUri, + scopes = null, + ) + + // Trigger URL generation + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify the URL is generated correctly without scopes + val loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain custom consumer key", loginUrl.contains(customConsumerKey)) + assertTrue("URL should contain custom redirect URI", + loginUrl.contains("redirect_uri=noscopes://redirect")) + // URL should still be valid even without explicit scopes + assertTrue("URL should be a valid OAuth URL", + loginUrl.contains("/services/oauth2/authorize")) + } + + @Test + fun reloadWebView_WithFrontDoorBridge_DoesNotReloadUrl() { + // Set up front door bridge + val frontDoorUrl = "https://test.salesforce.com/frontdoor.jsp?sid=test_session" + viewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, null) + + // Verify front door bridge is active + assertTrue("isUsingFrontDoorBridge should be true", viewModel.isUsingFrontDoorBridge) + assertEquals("loginUrl should be front door URL", frontDoorUrl, viewModel.loginUrl.value) + + // Call reloadWebView + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify URL did not change + assertEquals("loginUrl should still be front door URL", frontDoorUrl, viewModel.loginUrl.value) + } + + @Test + fun reloadWebView_WithUserAgentFlow_SetsAboutBlankFirst() { + try { + // Set to User Agent Flow (not Web Server Flow) + SalesforceSDKManager.getInstance().useWebServerAuthentication = false + + // Ensure we're not using front door bridge + assertFalse("isUsingFrontDoorBridge should be false", viewModel.isUsingFrontDoorBridge) + + // Get initial URL + val initialUrl = viewModel.loginUrl.value + assertNotNull("Initial URL should not be null", initialUrl) + assertNotEquals("Initial URL should not be ABOUT_BLANK", ABOUT_BLANK, initialUrl) + + // Call reloadWebView + viewModel.reloadWebView() + + // Verify URL was set to ABOUT_BLANK for User Agent Flow + // NOTE: If this is flaky we should use Turbine to test the actual state changes. + assertEquals("loginUrl should be set to ABOUT_BLANK for User Agent Flow", + ABOUT_BLANK, viewModel.loginUrl.value) + + // Wait for the new authorization URL to be generated + Thread.sleep(200) + + // Verify a new URL was generated + val newUrl = viewModel.loginUrl.value + assertNotNull("New URL should not be null", newUrl) + assertNotEquals("New URL should not be ABOUT_BLANK", ABOUT_BLANK, newUrl) + assertNotEquals("New URL should be different from initial", initialUrl, newUrl) + } finally { + SalesforceSDKManager.getInstance().useWebServerAuthentication = true + } + } + + @Test + fun reloadWebView_WithWebServerFlow_DoesNotSetAboutBlank() { + assert(SalesforceSDKManager.getInstance().useWebServerAuthentication) + // Ensure we're not using front door bridge + assertFalse("isUsingFrontDoorBridge should be false", viewModel.isUsingFrontDoorBridge) + + // Get initial URL + val initialUrl = viewModel.loginUrl.value + assertNotNull("Initial URL should not be null", initialUrl) + + // Call reloadWebView + viewModel.reloadWebView() + + // Give a brief moment to check if ABOUT_BLANK would be set + Thread.sleep(50) + + // Verify URL was NOT set to ABOUT_BLANK for Web Server Flow + assertNotEquals("loginUrl should NOT be ABOUT_BLANK for Web Server Flow", + ABOUT_BLANK, viewModel.loginUrl.value) + + // Wait for the new authorization URL to be generated + Thread.sleep(200) + + // Verify a new URL was generated with different code challenge + val newUrl = viewModel.loginUrl.value + assertNotNull("New URL should not be null", newUrl) + assertNotEquals("New URL should not be ABOUT_BLANK", ABOUT_BLANK, newUrl) + assertNotEquals("New URL should be different from initial (different code challenge)", + initialUrl, newUrl) + } + + @Test + fun reloadWebView_WithNullSelectedServer_DoesNothing() { + val initialUrl = "test" + viewModel.loginUrl.value = initialUrl + + // Set selectedServer to null + viewModel.selectedServer.value = null + Thread.sleep(100) + + // Call reloadWebView + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify URL did not change + assertEquals("loginUrl should not change when selectedServer is null", + initialUrl, viewModel.loginUrl.value) + } + + @Test + fun getAuthorizationUrl_UsesBootConfig_WhenAppConfigForLoginHostReturnsNull() { + val sdkManager = SalesforceSDKManager.getInstance() + val originalAppConfigForLoginHost = sdkManager.appConfigForLoginHost + + try { + // Ensure debugOverrideAppConfig is null + sdkManager.debugOverrideAppConfig = null + + // Set appConfigForLoginHost to return null + sdkManager.appConfigForLoginHost = { _ -> null } + + // Trigger URL generation + viewModel.reloadWebView() + Thread.sleep(200) + + // Verify the URL contains the boot config values (fallback) + val loginUrl = viewModel.loginUrl.value!! + assertTrue("URL should contain boot config consumer key when appConfigForLoginHost returns null", + loginUrl.contains(bootConfig.remoteAccessConsumerKey)) + assertTrue("URL should contain boot config redirect URI when appConfigForLoginHost returns null", + loginUrl.contains("redirect_uri=${bootConfig.oauthRedirectURI}")) + + // Verify boot config scopes are present + bootConfig.oauthScopes.forEach { scope -> + assertTrue("URL should contain boot config scope '$scope' when appConfigForLoginHost returns null", + loginUrl.contains(scope)) + } + } finally { + sdkManager.appConfigForLoginHost = originalAppConfigForLoginHost + } + } + private fun generateExpectedAuthorizationUrl( server: String, codeChallenge: String, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/OAuthConfigTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/OAuthConfigTest.kt new file mode 100644 index 0000000000..3d02beba79 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/config/OAuthConfigTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025-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.config + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for OAuthConfig. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class OAuthConfigTest { + + @Test + fun testPrimaryConstructorWithScopes() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = listOf("api", "web", "refresh_token") + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertEquals(listOf("api", "web", "refresh_token"), config.scopes) + assertEquals("api web refresh_token", config.scopesString) + } + + @Test + fun testPrimaryConstructorWithoutScopes() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback" + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertNull(config.scopes) + assert(config.scopesString.isEmpty()) + } + + @Test + fun testBootConfigConstructorWithScopes() { + val bootConfig = mockk() + every { bootConfig.remoteAccessConsumerKey } returns "boot_consumer_key" + every { bootConfig.oauthRedirectURI } returns "boot://redirect" + every { bootConfig.oauthScopes } returns arrayOf("api", "web", "refresh_token") + + val config = OAuthConfig(bootConfig) + + assertEquals("boot_consumer_key", config.consumerKey) + assertEquals("boot://redirect", config.redirectUri) + assertEquals(listOf("api", "web", "refresh_token"), config.scopes) + assertEquals("api web refresh_token", config.scopesString) + } + + @Test + fun testBootConfigConstructorWithEmptyScopes() { + val bootConfig = mockk() + every { bootConfig.remoteAccessConsumerKey } returns "boot_consumer_key" + every { bootConfig.oauthRedirectURI } returns "boot://redirect" + every { bootConfig.oauthScopes } returns arrayOf() + + val config = OAuthConfig(bootConfig) + + assertEquals("boot_consumer_key", config.consumerKey) + assertEquals("boot://redirect", config.redirectUri) + assertNull(config.scopes) + assert(config.scopesString.isEmpty()) + } + + @Test + fun testStringConstructorWithCommaSeparatedScopes() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = "api,web,refresh_token" + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertEquals(listOf("api", "web", "refresh_token"), config.scopes) + } + + @Test + fun testStringConstructorWithSpaceSeparatedScopes() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = "api web refresh_token" + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertEquals(listOf("api", "web", "refresh_token"), config.scopes) + } + + @Test + fun testStringConstructorWithCommaSeparatedScopesAndWhitespace() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = " api , web , refresh_token " + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertEquals(listOf("api", "web", "refresh_token"), config.scopes) + } + + @Test + fun testStringConstructorWithEmptyString() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = "" + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertNull(config.scopes) + } + + @Test + fun testStringConstructorWithBlankString() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = " " + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertNull(config.scopes) + } + + @Test + fun testStringConstructorTrimsConsumerKeyAndRedirectUri() { + val config = OAuthConfig( + consumerKey = " test_consumer_key ", + redirectUri = " test://callback ", + scopes = "api" + ) + + assertEquals("test_consumer_key", config.consumerKey) + assertEquals("test://callback", config.redirectUri) + assertEquals(listOf("api"), config.scopes) + } + + @Test + fun testScopesStringWithNullScopes() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = null + ) + + assertEquals("", config.scopesString) + } + + @Test + fun testScopesStringWithEmptyScopes() { + val config = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "test://callback", + scopes = emptyList() + ) + + assertEquals("", config.scopesString) + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt index 098a970506..9da29b2bb1 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt @@ -654,7 +654,7 @@ class DevSupportInfoTest { "Instance URL", "https://test.salesforce.com", "Token Format", "oauth2", "Access Token Expiration", "Unknown", - "Beacon Child Consumer Key", user.beaconChildConsumerKey, + "Beacon Child Consumer Key", user.beaconChildConsumerKey ?: "None", )) // Add runtime config diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java index e474fe9d06..42816fd788 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java @@ -730,7 +730,7 @@ public void testQueryAll() throws Exception { * Create new account then look for it using soql. * @throws Exception */ - @Test + @Test(timeout = 180000) // 3 minutes - test creates 201 accounts which takes time, especially in Firebase Test Lab public void testQueryWithBatchSize() throws Exception { cleanup(); List idNames = createAccounts(201, "-testWithBatchSize-"); diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt index d36f44d63c..6e5e2d6f72 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt @@ -162,7 +162,9 @@ class DevInfoActivityTest { assertTrue("Boot config should not be empty", items.isNotEmpty()) items.forEach { (key, _) -> - composeTestRule.onNodeWithText(key).assertIsDisplayed() + composeTestRule.onNodeWithText(key) + .performScrollTo() + .assertIsDisplayed() } } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt index 41041c2ca8..c701a8fcae 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt @@ -28,7 +28,9 @@ package com.salesforce.androidsdk.ui import android.Manifest import android.os.Build +import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals @@ -36,21 +38,29 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import com.salesforce.androidsdk.R import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.OAuthConfig import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +private const val CONSUMER_KEY_LABEL = "Consumer Key" +private const val REDIRECT_URI_LABEL = "Redirect URI" +private const val SCOPES_LABEL = "Scopes" + @RunWith(AndroidJUnit4::class) class LoginOptionsActivityTest { @@ -68,6 +78,14 @@ class LoginOptionsActivityTest { private var originalUseWebServer: Boolean = false private var originalUseHybridToken: Boolean = false private var originalSupportsWelcomeDiscovery: Boolean = false + private lateinit var dynamicToggle: SemanticsNodeInteraction + private lateinit var consumerKeyField: SemanticsNodeInteraction + private lateinit var redirectUriField: SemanticsNodeInteraction + private lateinit var scopesField: SemanticsNodeInteraction + private lateinit var webserverToggle: SemanticsNodeInteraction + private lateinit var hybridToggle: SemanticsNodeInteraction + private lateinit var welcomeToggle: SemanticsNodeInteraction + private lateinit var saveButton: SemanticsNodeInteraction @Before fun setup() { @@ -76,6 +94,31 @@ class LoginOptionsActivityTest { originalUseHybridToken = SalesforceSDKManager.getInstance().useHybridAuthentication originalSupportsWelcomeDiscovery = SalesforceSDKManager.getInstance().supportsWelcomeDiscovery SalesforceSDKManager.getInstance().loginDevMenuReload = false + + dynamicToggle = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_dynamic_config_toggle_content_description), + ) + consumerKeyField = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_consumer_key_field_content_description), + ) + redirectUriField = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_redirect_uri_field_content_description), + ) + scopesField = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_scopes_field_content_description), + ) + webserverToggle = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description), + ) + hybridToggle = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description), + ) + welcomeToggle = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_welcome_toggle_content_description), + ) + saveButton = composeTestRule.onNodeWithText( + composeTestRule.activity.getString(R.string.sf__server_url_save), + ) } @After @@ -84,6 +127,8 @@ class LoginOptionsActivityTest { SalesforceSDKManager.getInstance().useWebServerAuthentication = originalUseWebServer SalesforceSDKManager.getInstance().useHybridAuthentication = originalUseHybridToken SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = originalSupportsWelcomeDiscovery + SalesforceSDKManager.getInstance().debugOverrideAppConfig = null + SalesforceSDKManager.getInstance().loginDevMenuReload = false } @Test @@ -115,8 +160,6 @@ class LoginOptionsActivityTest { SalesforceSDKManager.getInstance().useWebServerAuthentication ) - val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description) - val webserverToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor) webserverToggle.assertIsDisplayed() webserverToggle.assertIsOff() @@ -137,12 +180,6 @@ class LoginOptionsActivityTest { "Use Web Server Authentication should be enabled", SalesforceSDKManager.getInstance().useWebServerAuthentication ) - - // Verify the reload flag was set - assertTrue( - "loginDevMenuReload should be true after toggle", - SalesforceSDKManager.getInstance().loginDevMenuReload - ) } @Test @@ -161,8 +198,6 @@ class LoginOptionsActivityTest { ) // Find and click the toggle - val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description) - val hybridToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor) hybridToggle.assertIsDisplayed() hybridToggle.assertIsOff() @@ -183,12 +218,6 @@ class LoginOptionsActivityTest { "Use Hybrid Authentication should be enabled", SalesforceSDKManager.getInstance().useHybridAuthentication ) - - // Verify the reload flag was set - assertTrue( - "loginDevMenuReload should be true after toggle", - SalesforceSDKManager.getInstance().loginDevMenuReload - ) } @Test @@ -207,8 +236,6 @@ class LoginOptionsActivityTest { ) // Find and click the toggle - val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_welcome_toggle_content_description) - val welcomeToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor) welcomeToggle.assertIsDisplayed() welcomeToggle.assertIsOff() @@ -229,12 +256,6 @@ class LoginOptionsActivityTest { "Support Welcome Discovery should be enabled", SalesforceSDKManager.getInstance().supportsWelcomeDiscovery ) - - // Verify the reload flag was set - assertTrue( - "loginDevMenuReload should be true after toggle", - SalesforceSDKManager.getInstance().loginDevMenuReload - ) } @Test @@ -261,32 +282,19 @@ class LoginOptionsActivityTest { @Test fun loginOptionsActivity_DynamicBootConfigToggle_ShowsInputFields() { - // Get content descriptions - val dynamicToggleDesc = composeTestRule.activity.getString(R.string.sf__login_options_dynamic_config_toggle_content_description) - val consumerKeyFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_consumer_key_field_content_description) - val redirectUriFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_redirect_uri_field_content_description) - val scopesFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_scopes_field_content_description) - - // Initially, dynamic config fields should not exist - val dynamicToggle = composeTestRule.onNodeWithContentDescription(dynamicToggleDesc) dynamicToggle.assertIsDisplayed() dynamicToggle.assertIsOff() - composeTestRule.onNodeWithContentDescription(consumerKeyFieldDesc).assertDoesNotExist() - composeTestRule.onNodeWithContentDescription(redirectUriFieldDesc).assertDoesNotExist() - composeTestRule.onNodeWithContentDescription(scopesFieldDesc).assertDoesNotExist() + consumerKeyField.assertDoesNotExist() + redirectUriField.assertDoesNotExist() + scopesField.assertDoesNotExist() // Click to enable dynamic config dynamicToggle.performClick() composeTestRule.waitForIdle() dynamicToggle.assertIsOn() - - // Now the input fields should be visible and accept text input - val consumerKeyField = composeTestRule.onNodeWithContentDescription(consumerKeyFieldDesc) - val redirectUriField = composeTestRule.onNodeWithContentDescription(redirectUriFieldDesc) - val scopesField = composeTestRule.onNodeWithContentDescription(scopesFieldDesc) - + saveButton.performScrollTo() consumerKeyField.assertIsDisplayed() redirectUriField.assertIsDisplayed() scopesField.assertIsDisplayed() @@ -299,9 +307,9 @@ class LoginOptionsActivityTest { composeTestRule.waitForIdle() // Verify the text was entered - consumerKeyField.assertTextEquals("Consumer Key", "test_consumer_key") - redirectUriField.assertTextEquals("Redirect URI", "test://redirect") - scopesField.assertTextEquals("Scopes", "api web") + consumerKeyField.assertTextEquals(CONSUMER_KEY_LABEL, "test_consumer_key") + redirectUriField.assertTextEquals(REDIRECT_URI_LABEL, "test://redirect") + scopesField.assertTextEquals(SCOPES_LABEL, "api web") } @Test @@ -322,35 +330,129 @@ class LoginOptionsActivityTest { ) // Toggle web server flow - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description) - ).performClick() + webserverToggle.performClick() composeTestRule.waitForIdle() // Verify only web server flow changed assertTrue(SalesforceSDKManager.getInstance().useWebServerAuthentication) assertFalse(SalesforceSDKManager.getInstance().useHybridAuthentication) - // Verify the reload flag was set - assertTrue( - "loginDevMenuReload should be true after first toggle", - SalesforceSDKManager.getInstance().loginDevMenuReload - ) - // Toggle hybrid auth token - composeTestRule.onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description) - ).performClick() + hybridToggle.performClick() composeTestRule.waitForIdle() // Verify both are now enabled assertTrue(SalesforceSDKManager.getInstance().useWebServerAuthentication) assertTrue(SalesforceSDKManager.getInstance().useHybridAuthentication) - - // Verify the reload flag is still true - assertTrue( - "loginDevMenuReload should still be true after second toggle", - SalesforceSDKManager.getInstance().loginDevMenuReload + } + + @Test + fun bootConfigView_WithEmptyFields_DisablesSaveButton() { + // Enable dynamic config + dynamicToggle.performClick() + composeTestRule.waitForIdle() + + // Save button should be disabled when fields are empty + saveButton.performScrollTo() + saveButton.assertIsDisplayed() + saveButton.assertIsNotEnabled() + } + + @Test + fun bootConfigView_TappingSaveButton_SetsDebugOverrideAppConfig() { + // Enable dynamic config + dynamicToggle.performClick() + composeTestRule.waitForIdle() + + consumerKeyField.performTextInput("override_key") + redirectUriField.performTextInput("override://uri") + scopesField.performTextInput("api web") + composeTestRule.waitForIdle() + + // Click save button + saveButton.performScrollTo().performClick() + composeTestRule.waitForIdle() + + // Verify debugOverrideAppConfig was set + val overrideConfig = SalesforceSDKManager.getInstance().debugOverrideAppConfig + assertNotNull("debugOverrideAppConfig should be set", overrideConfig) + assertEquals("override_key", overrideConfig?.consumerKey) + assertEquals("override://uri", overrideConfig?.redirectUri) + assertEquals(listOf("api", "web"), overrideConfig?.scopes) + } + + @Test + fun bootConfigView_SaveWithOnlyRequiredFields_CreatesOAuthConfig() { + // Enable dynamic config + dynamicToggle.performClick() + composeTestRule.waitForIdle() + + consumerKeyField.performTextInput("minimal_key") + redirectUriField.performTextInput("minimal://uri") + composeTestRule.waitForIdle() + + // Click save button + saveButton.performScrollTo().performClick() + composeTestRule.waitForIdle() + + // Verify debugOverrideAppConfig was set with null scopes + val overrideConfig = SalesforceSDKManager.getInstance().debugOverrideAppConfig + assertNotNull("debugOverrideAppConfig should be set", overrideConfig) + assertEquals("minimal_key", overrideConfig?.consumerKey) + assertEquals("minimal://uri", overrideConfig?.redirectUri) + assertNull("Scopes should be null when not provided", overrideConfig?.scopes) + } + + @Test + fun bootConfigView_TogglingOffDebugOverride_ClearsValues() { + // Set an override first + SalesforceSDKManager.getInstance().debugOverrideAppConfig = OAuthConfig( + "existing_key", + "existing://uri", + listOf("api") ) + + // Recreate the activity to show the config we just set + with(composeTestRule.activity) { + runOnUiThread { recreate() } + } + + // Enable dynamic config + dynamicToggle.performScrollTo().performClick() + composeTestRule.waitForIdle() + + assertNull("Override should be cleared", SalesforceSDKManager.getInstance().debugOverrideAppConfig) + + // Toggle off dynamic config + dynamicToggle.performScrollTo().performClick() + composeTestRule.waitForIdle() + + // Verify override was cleared + assertNull("Override should be cleared when toggling off", SalesforceSDKManager.getInstance().debugOverrideAppConfig) + } + + @Test + fun bootConfigView_PrePopulatesWithExistingConfig() { + val overrideKey = "existing_key" + val overrideUri = "existing://uri" + val overrideScopes = listOf("api", "web") + val existingConfig = OAuthConfig(overrideKey, overrideUri, overrideScopes) + SalesforceSDKManager.getInstance().debugOverrideAppConfig = existingConfig + + // Recreate the activity to show the config we just set + with(composeTestRule.activity) { + runOnUiThread { recreate() } + } + + consumerKeyField.assertTextEquals(CONSUMER_KEY_LABEL, overrideKey) + redirectUriField.assertTextEquals(REDIRECT_URI_LABEL, overrideUri) + scopesField.assertTextEquals(SCOPES_LABEL, existingConfig.scopesString) + } + + @Test + fun leavingActivity_Sets_loginDevMenuReload() { + assertFalse(SalesforceSDKManager.getInstance().loginDevMenuReload) + composeTestRule.activity.finish() + assertTrue(SalesforceSDKManager.getInstance().loginDevMenuReload) } -} +} \ No newline at end of file