diff --git a/.github/test-shards/SalesforceSDK.json b/.github/test-shards/SalesforceSDK.json index 140bf7cf37..c60ffd5dd5 100644 --- a/.github/test-shards/SalesforceSDK.json +++ b/.github/test-shards/SalesforceSDK.json @@ -3,7 +3,7 @@ "shards": [ { "name": "network", - "comment": "REST client tests that make live server calls - isolated", + "comment": "REST client tests that make live server calls", "targets": [ "class com.salesforce.androidsdk.rest.RestClientTest", "class com.salesforce.androidsdk.rest.ClientManagerTest", @@ -14,7 +14,7 @@ }, { "name": "ui", - "comment": "UI tests and security tests that may require user interaction", + "comment": "Tests that exercise Activities/UI", "targets": [ "class com.salesforce.androidsdk.ui.LoginViewActivityTest", "class com.salesforce.androidsdk.ui.PickerBottomSheetTest", @@ -23,7 +23,9 @@ "class com.salesforce.androidsdk.ui.PickerBottomSheetActivityTest", "class com.salesforce.androidsdk.ui.DevInfoActivityTest", "class com.salesforce.androidsdk.security.ScreenLockManagerTest", - "class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest" + "class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest", + "class com.salesforce.androidsdk.ui.TokenMigrationActivityTest", + "class com.salesforce.androidsdk.ui.TokenMigrationWebViewTest" ] }, { @@ -68,7 +70,9 @@ "notClass com.salesforce.androidsdk.rest.NotificationsActionsResponseBodyTest", "notClass com.salesforce.androidsdk.rest.files.RenditionTypeTest", "notClass com.salesforce.androidsdk.rest.files.ConnectUriBuilderTest", - "notClass com.salesforce.androidsdk.rest.files.FileRequestsTest" + "notClass com.salesforce.androidsdk.rest.files.FileRequestsTest", + "notClass com.salesforce.androidsdk.ui.TokenMigrationActivityTest", + "notClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest" ] } ] diff --git a/.github/workflows/reusable-lib-workflow.yaml b/.github/workflows/reusable-lib-workflow.yaml index 89c905ecb1..6ca361a71f 100644 --- a/.github/workflows/reusable-lib-workflow.yaml +++ b/.github/workflows/reusable-lib-workflow.yaml @@ -184,7 +184,7 @@ jobs: fi done - name: Test Report - uses: mikepenz/action-junit-report@v5 + uses: mikepenz/action-junit-report if: success() || failure() with: check_name: ${{ inputs.lib }} Test Results diff --git a/.github/workflows/reusable-ui-workflow.yaml b/.github/workflows/reusable-ui-workflow.yaml index 3a4b8c3c9d..24de592d11 100644 --- a/.github/workflows/reusable-ui-workflow.yaml +++ b/.github/workflows/reusable-ui-workflow.yaml @@ -127,7 +127,7 @@ jobs: fi done - name: Test Report - uses: mikepenz/action-junit-report@v5 + uses: mikepenz/action-junit-report if: success() || failure() with: check_name: ${{ inputs.lib }} Test Results diff --git a/libs/SalesforceSDK/AndroidManifest.xml b/libs/SalesforceSDK/AndroidManifest.xml index 8bca33a2a7..3d9477e435 100644 --- a/libs/SalesforceSDK/AndroidManifest.xml +++ b/libs/SalesforceSDK/AndroidManifest.xml @@ -45,6 +45,12 @@ android:configChanges="orientation|screenLayout|uiMode|screenSize|smallestScreenSize" android:exported="true" /> + + + Unit, + onMigrationError: (error: String, errorDesc: String?, e: Throwable?) -> Unit, +) { + val loggedOnSuccess: (userAccount: UserAccount) -> Unit = { user -> + SalesforceSDKLogger.i(TAG, "Token Migration Successful \n\nUser ${user.username} " + + "(${user.instanceServer}) successfully migrated to: \n$appConfig.") + onMigrationSuccess.invoke(user) + } + val userId = userAccount?.userId + val orgId = userAccount?.orgId + + if (userId == null || orgId == null) { + val message = "User account, userId or orgId is null." + SalesforceSDKLogger.e(TAG, message) + onMigrationError(message, null, null) + return + } + + val callbackKey = MigrationCallbackRegistry.register( + callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = loggedOnSuccess, + onMigrationError = onMigrationError, + ) + ) + + with(SalesforceSDKManager.getInstance().appContext) { + startActivity( + Intent(/* packageContext = */ this, TokenMigrationActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig) + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + } + ) + } +} + +/* + This mechanism is used to pass a _string_ id to the Activity to retrieve callback functions. + + Lambda functions may appear Parcelable/Serializable but since we cannot guarantee the + content are they should not be passed. For instance, if the lambda function contains + compose state an exception will be thrown. + */ +internal object MigrationCallbackRegistry { + private val callbacks = mutableMapOf() + + data class MigrationCallbacks( + val onMigrationSuccess: (UserAccount) -> Unit, + val onMigrationError: (String, String?, Throwable?) -> Unit + ) + + fun register(callbacks: MigrationCallbacks): String { + val key = UUID.randomUUID().toString() + this.callbacks[key] = callbacks + return key + } + + fun consume(key: String): MigrationCallbacks? = callbacks.remove(key) +} \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index b5dcc3c7cc..9a5281d82a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -47,7 +47,6 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.addAuthorizationHeader import com.salesforce.androidsdk.auth.OAuth2.callIdentityService -import com.salesforce.androidsdk.auth.OAuth2.revokeRefreshToken import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.RuntimeConfig import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig @@ -98,6 +97,7 @@ internal suspend fun onAuthFlowComplete( onAuthFlowSuccess: (userAccount: UserAccount) -> Unit, buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName, nativeLogin: Boolean = false, + tokenMigration: Boolean = false, context: Context = SalesforceSDKManager.getInstance().appContext, userAccountManager: UserAccountManager = SalesforceSDKManager.getInstance().userAccountManager, blockIntegrationUser: Boolean = (SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser && @@ -110,7 +110,8 @@ internal suspend fun onAuthFlowComplete( addAccount: (account: UserAccount) -> Unit = ::addAccountHelper, 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, + handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit + = { uam, acct, identity -> com.salesforce.androidsdk.auth.handleDuplicateUserAccount(uam, acct, identity) }, ) { // Reset Dev Support LoginOptionsActivity override SalesforceSDKManager.getInstance().debugOverrideAppConfig = null @@ -189,9 +190,11 @@ internal suspend fun onAuthFlowComplete( } userAccountManager.sendUserSwitchIntent(userSwitchType, null) - // Kickoff the end of the flow before storing mobile policy to prevent launching - // the main activity over/after the screen lock. - startMainActivity() + if (!tokenMigration) { + // Kickoff the end of the flow before storing mobile policy to prevent launching + // the main activity over/after the screen lock. + startMainActivity() + } // Let the calling process resume onAuthFlowSuccess(account) @@ -371,36 +374,51 @@ private fun updateLoggingPrefsHelper(account: UserAccount) { /** * Helper method to handle screen lock mobile policy. */ -private fun handleScreenLockPolicy( +@VisibleForTesting +internal fun handleScreenLockPolicy( userIdentity: OAuth2.IdServiceResponse?, - account: UserAccount + account: UserAccount, ) { + val internalScreenLockManager = + SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager? + + // compareTo(0) is used to check if screenLockTimeout is non-null and greater than 0. if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) { SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK) val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60 - (SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy( + internalScreenLockManager?.storeMobilePolicy( account, - userIdentity.screenLock, - timeoutInMills + enabled = userIdentity.screenLock, + timeoutInMills, ) + } else if (internalScreenLockManager?.enabled == true) { + SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) + internalScreenLockManager.cleanUp(account) } } /** * Helper method to handle biometric authentication mobile policy. */ -private fun handleBiometricAuthPolicy( +@VisibleForTesting +internal fun handleBiometricAuthPolicy( userIdentity: OAuth2.IdServiceResponse?, - account: UserAccount + account: UserAccount, ) { + val internalBiometricAuthenticationManager = + SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager? + if (userIdentity?.biometricAuth == true) { SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000 - (SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy( + internalBiometricAuthenticationManager?.storeMobilePolicy( account, - userIdentity.biometricAuth, + enabled = userIdentity.biometricAuth, timeoutInMills ) + } else if (internalBiometricAuthenticationManager?.enabled == true) { + SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) + internalBiometricAuthenticationManager.cleanUp(account) } } @@ -426,10 +444,12 @@ private fun addAccountHelper( * - Unlocking biometric authentication for the duplicate user * - Signing out other users with biometric auth when a new biometric user is added */ -private fun handleDuplicateUserAccount( +@VisibleForTesting +internal fun handleDuplicateUserAccount( userAccountManager: UserAccountManager, account: UserAccount, - userIdentity: OAuth2.IdServiceResponse? + userIdentity: OAuth2.IdServiceResponse?, + revokeRefreshToken: (HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit = OAuth2::revokeRefreshToken, ) { userAccountManager.authenticatedUsers?.let { existingUsers -> // Check if the user already exists @@ -451,14 +471,12 @@ private fun handleDuplicateUserAccount( as? BiometricAuthenticationManager)?.onUnlock() } CoroutineScope(IO).launch { - CoroutineScope(IO).launch { - revokeRefreshToken( - HttpAccess.DEFAULT, - uri, - duplicateUserAccount.refreshToken, - OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED, - ) - } + revokeRefreshToken( + HttpAccess.DEFAULT, + uri, + duplicateUserAccount.refreshToken, + OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED, + ) } } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index f5d5072839..2dbaf20b96 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -133,6 +133,7 @@ public class OAuth2 { private static final String QUESTION = "?"; private static final String TOUCH = "touch"; private static final String FRONTDOOR = "/secur/frontdoor.jsp?"; + public static final String FRONTDOOR_URL_KEY = "frontdoor_uri"; private static final String SID = "sid"; private static final String RETURL = "retURL"; protected static final String AUTHORIZATION = "Authorization"; 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 1e617875cd..42429daf39 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt @@ -33,6 +33,7 @@ import android.webkit.WebViewClient import com.salesforce.androidsdk.R import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl import com.salesforce.androidsdk.rest.ClientManager import com.salesforce.androidsdk.rest.RestClient @@ -132,7 +133,7 @@ internal class IDPAuthCodeHelper private constructor( SalesforceSDKLogger.e(TAG, "Failed to obtain valid front door url", e) null } - return if (restResponse == null || !restResponse.isSuccess) null else restResponse.asJSONObject().getString("frontdoor_uri") + return if (restResponse == null || !restResponse.isSuccess) null else restResponse.asJSONObject().getString(FRONTDOOR_URL_KEY) } private fun onError(error: String, exception: java.lang.Exception? = null) { diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt index 51ff03f7a0..96d6fd342e 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt @@ -26,11 +26,15 @@ */ package com.salesforce.androidsdk.config +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class OAuthConfig( val consumerKey: String, val redirectUri: String, val scopes: List? = null, -) { +): Parcelable { internal constructor(bootConfig: BootConfig): this( bootConfig.remoteAccessConsumerKey, diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 65b19ae72a..1ae35817bc 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -239,6 +239,7 @@ open class LoginActivity : FragmentActivity() { newUserIntent = true } + // TODO: Move to non-deprecated getParcelableExtra when min API >= 33 accountAuthenticatorResponse = intent.getParcelableExtra( KEY_ACCOUNT_AUTHENTICATOR_RESPONSE )?.apply { @@ -1217,18 +1218,6 @@ open class LoginActivity : FragmentActivity() { d(TAG, "Received client certificate request from server") request.proceed(key, certChain) } - - private fun validateAndExtractBackgroundColor(javaScriptResult: String): Color? { - val rgbMatch = rgbTextPattern.find(javaScriptResult) - - // groupValues[0] is the entire match. [1] is red, [2] is green, [3] is green. - rgbMatch?.groupValues?.get(3) ?: return null - val red = rgbMatch.groupValues[1].toIntOrNull() ?: return null - val green = rgbMatch.groupValues[2].toIntOrNull() ?: return null - val blue = rgbMatch.groupValues[3].toIntOrNull() ?: return null - - return Color(red, green, blue) - } } companion object { @@ -1245,15 +1234,27 @@ open class LoginActivity : FragmentActivity() { private const val RESPONSE_ERROR_DESCRIPTION_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR_DESCRIPTION" // This parses the expected "rgb(x, x, x)" string. - private val rgbTextPattern = "rgb\\((\\d{1,3}), (\\d{1,3}), (\\d{1,3})\\)".toRegex() + internal val rgbTextPattern = "rgb\\((\\d{1,3}), (\\d{1,3}), (\\d{1,3})\\)".toRegex() // endregion // region LoginWebviewClient Constants internal const val ABOUT_BLANK = "about:blank" - private const val BACKGROUND_COLOR_JAVASCRIPT = + internal const val BACKGROUND_COLOR_JAVASCRIPT = "(function() { return window.getComputedStyle(document.body, null).getPropertyValue('background-color'); })();" + internal fun validateAndExtractBackgroundColor(javaScriptResult: String): Color? { + val rgbMatch = rgbTextPattern.find(javaScriptResult) + + // groupValues[0] is the entire match. [1] is red, [2] is green, [3] is green. + rgbMatch?.groupValues?.get(3) ?: return null + val red = rgbMatch.groupValues[1].toIntOrNull() ?: return null + val green = rgbMatch.groupValues[2].toIntOrNull() ?: return null + val blue = rgbMatch.groupValues[3].toIntOrNull() ?: return null + + return Color(red, green, blue) + } + // endregion // region Log In Via Salesforce Identity API UI Bridge Front Door URL Public Implementation diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 7ff1128179..3fcf9b5dc9 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -355,8 +355,10 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { code: String?, onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit, onAuthFlowSuccess: (userAccount: UserAccount) -> Unit, + loginServer: String? = null, + tokenMigration: Boolean = false, ) = CoroutineScope(IO).launch { - doCodeExchange(code, onAuthFlowError, onAuthFlowSuccess) + doCodeExchange(code, onAuthFlowError, onAuthFlowSuccess, loginServer, tokenMigration) } /** @@ -368,6 +370,8 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { tr: TokenEndpointResponse, onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit, onAuthFlowSuccess: (userAccount: UserAccount) -> Unit, + tokenMigration: Boolean = false, + loginServer: String? = null, ) { // Clear cookies after successful authentication to prevent automatic re-login if the user tries to add another user right away. if (SalesforceSDKManager.getInstance().clearCookiesAfterLogin) { @@ -376,11 +380,12 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { authCodeForJwtFlow = null onAuthFlowComplete( tokenResponse = tr, - loginServer = selectedServer.value ?: "", // This will never actually be null. + loginServer = loginServer ?: selectedServer.value ?: "", consumerKey = consumerKey, onAuthFlowError = onAuthFlowError, onAuthFlowSuccess = onAuthFlowSuccess, buildAccountName = ::buildAccountName, + tokenMigration = tokenMigration, ) } @@ -427,22 +432,23 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { * @param scope The Coroutine scope. This parameter is intended for testing * purposes only. Defaults to the IO scope */ - @VisibleForTesting internal suspend fun getAuthorizationUrl( server: String, sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(), scope: CoroutineScope = CoroutineScope(IO), + migrationOAuthConfig: OAuthConfig? = null, ) = withContext(scope.coroutineContext) { // Show loading indicator because appConfigForLoginHost could take a noticeable amount of time. loading.value = true with(sdkManager) { - // Check if the OAuth Config has been manually set by dev support LoginOptionsActivity. - val debugOverrideAppConfig = debugOverrideAppConfig - oAuthConfig = if (isDebugBuild && debugOverrideAppConfig != null) { - debugOverrideAppConfig - } else { - appConfigForLoginHost(server) ?: OAuthConfig(bootConfig) + oAuthConfig = when { + // Used by UserAccountManager.migrateRefreshToken/TokenMigrationActivity + migrationOAuthConfig != null -> migrationOAuthConfig + // Used by LoginOptions + isDebugBuild && debugOverrideAppConfig != null -> debugOverrideAppConfig!! + // Check if app has a config and fallback to bootconfig file. + else -> appConfigForLoginHost(server) ?: OAuthConfig(bootConfig) } } @@ -483,13 +489,20 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { }.toString() } - private suspend fun doCodeExchange( + @VisibleForTesting + internal suspend fun doCodeExchange( code: String?, onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit, onAuthFlowSuccess: (userAccount: UserAccount) -> Unit, + loginServer: String? = null, + tokenMigration: Boolean = false, ) = withContext(IO) { runCatching { - val server = if (isUsingFrontDoorBridge) frontdoorBridgeServer else selectedServer.value + val server = when { + loginServer != null -> loginServer + isUsingFrontDoorBridge -> frontdoorBridgeServer + else -> selectedServer.value + } val verifier = if (isUsingFrontDoorBridge) frontdoorBridgeCodeVerifier else codeVerifier val tokenResponse = exchangeCode( @@ -501,7 +514,7 @@ open class LoginViewModel(val bootConfig: BootConfig) : ViewModel() { oAuthConfig.redirectUri, ) - onAuthFlowComplete(tokenResponse, onAuthFlowError, onAuthFlowSuccess) + onAuthFlowComplete(tokenResponse, onAuthFlowError, onAuthFlowSuccess, tokenMigration, server) }.onFailure { throwable -> e(TAG, "Exception occurred while making token request", throwable) onAuthFlowError("Token Request Error", throwable.message, throwable) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt new file mode 100644 index 0000000000..eb256d1663 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/TokenMigrationActivity.kt @@ -0,0 +1,317 @@ +/* + * 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.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.annotation.VisibleForTesting +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Color +import androidx.core.net.toUri +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry +import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK +import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY +import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse +import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.rest.RestRequest +import com.salesforce.androidsdk.ui.LoginActivity.Companion.BACKGROUND_COLOR_JAVASCRIPT +import com.salesforce.androidsdk.ui.LoginActivity.Companion.validateAndExtractBackgroundColor +import com.salesforce.androidsdk.ui.components.TokenMigrationView +import com.salesforce.androidsdk.util.SalesforceSDKLogger +import com.salesforce.androidsdk.util.UriFragmentParser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.lang.String.format + +private const val HALF_ALPHA = 0.5f + +// Error messages - internal for testing +@VisibleForTesting +internal const val ERROR_PARSE_CALLBACK_ID = "Unable to parse MigrationResult callback id." +@VisibleForTesting +internal const val ERROR_RETRIEVE_CALLBACK = "Unable to retrieve MigrationResult callback." +@VisibleForTesting +internal const val ERROR_PARSE_OAUTH_CONFIG = "Unable to parse OAuthConfig." +@VisibleForTesting +internal const val ERROR_BUILD_USER_ACCOUNT = "Unable to build user account." +@VisibleForTesting +internal const val ERROR_BUILD_REST_CLIENT = "Unable to build RestClient." +@VisibleForTesting +internal const val ERROR_SINGLE_ACCESS_FAILED = "Request for single access bridge url failed" +@VisibleForTesting +internal const val ERROR_TOKEN_INVALID_DESC = "User's existing token may be invalid." + +internal class TokenMigrationActivity : ComponentActivity() { + + private val viewModel: LoginViewModel + by viewModels { SalesforceSDKManager.getInstance().loginViewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val callbackKey = intent.getStringExtra(EXTRA_CALLBACK_ID) ?: run { + SalesforceSDKLogger.e(TAG, ERROR_PARSE_CALLBACK_ID) + finish() + return + } + val resultCallback = MigrationCallbackRegistry.consume(callbackKey) ?: run { + SalesforceSDKLogger.e(TAG, ERROR_RETRIEVE_CALLBACK) + finish() + return + } + + // TODO: Move to non-deprecated getParcelableExtra when min API >= 33 + val oAuthConfig = intent.getParcelableExtra(EXTRA_OAUTH_CONFIG) ?: run { + logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG, null, null) + return + } + + val orgId = intent.getStringExtra(EXTRA_ORG_ID) + val userId = intent.getStringExtra(EXTRA_USER_ID) + if ( orgId == null || userId == null) { + logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG, null, null) + return + } + val user = UserAccountManager.getInstance().getUserFromOrgAndUserId(orgId, userId) ?: run { + logMigrationError(resultCallback, ERROR_BUILD_USER_ACCOUNT, null, null) + return + } + val client = runCatching { + SalesforceSDKManager.getInstance().clientManager.peekRestClient(user) + }.getOrElse { e -> + logMigrationError(resultCallback, ERROR_BUILD_REST_CLIENT, null, e as? Exception) + return + } + + lifecycleScope.launch { + val frontDoorUrl = withContext(IO) { + runCatching { + val authorizationUrl = viewModel.getAuthorizationUrl( + server = user.instanceServer, + migrationOAuthConfig = oAuthConfig, + ) + val authorizationPath = with(authorizationUrl.toUri()) { "$path?$query" } + val request = RestRequest.getRequestForSingleAccess(authorizationPath) + val singleAccessResponse = client.sendSync(request) + + singleAccessResponse + ?.takeIf { it.isSuccess } + ?.let { + Json.parseToJsonElement(it.asString()) + .jsonObject[FRONTDOOR_URL_KEY] + ?.jsonPrimitive?.content + } + }.getOrNull() + } ?: run { + logMigrationError( + resultCallback = resultCallback, + error = ERROR_SINGLE_ACCESS_FAILED, + errorDesc = ERROR_TOKEN_INVALID_DESC, + e = null, + ) + return@launch + } + + // Initially set background to transparent, it will react to the webview + // background if/when the WebView loads an actual page we want to show. + viewModel.dynamicBackgroundColor.value = Color.Transparent.copy(alpha = HALF_ALPHA) + makeStatusBarVisible() + + setContent { + MaterialTheme( + colorScheme = SalesforceSDKManager.getInstance().colorScheme().copy( + background = viewModel.dynamicBackgroundColor.value + ) + ) { + TokenMigrationView( + webViewFactory = { buildAuthWebview(frontDoorUrl, resultCallback, user.instanceServer) } + ) + } + } + } + } + + @VisibleForTesting + internal fun buildAuthWebview( + frontDoorUrl: String, + resultCallback: MigrationCallbackRegistry.MigrationCallbacks, + instanceServer: String, + ): WebView = webViewFactory(this@TokenMigrationActivity).apply { + @SuppressLint("SetJavaScriptEnabled") // Required by Salesforce + settings.javaScriptEnabled = true + settings.userAgentString = format( + "%s %s", + SalesforceSDKManager.getInstance().userAgent, + settings.userAgentString, + ) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + webViewClient = TokenMigrationClientManager(resultCallback, instanceServer) + + loadUrl(frontDoorUrl) + } + + // This implementation is very similar to [LoginActivity.AuthWebViewClient] but the + // code cannot be shared due to the heavy reliance on the ViewModel. + @VisibleForTesting + internal inner class TokenMigrationClientManager( + val resultCallback: MigrationCallbackRegistry.MigrationCallbacks, + val instanceServer: String, + ) : WebViewClient() { + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + val url = request.url.toString().replace("///", "/").lowercase() + val callbackUrl = viewModel.oAuthConfig.redirectUri.replace("///", "/").lowercase() + val migrationFinished = url.startsWith(callbackUrl) + + if (migrationFinished) { + viewModel.authFinished.value = true + viewModel.loading.value = true + + val params = UriFragmentParser.parse(request.url) + val error = params["error"] + // Did we fail? + when { + error != null -> { + logMigrationError( + resultCallback = resultCallback, + error = error, + errorDesc = params["error_description"], + e = null, + ) + } + + else -> { + // Show loading while we PKCE and/or create user account. + viewModel.authFinished.value = true + viewModel.loading.value = true + + CoroutineScope(Default).launch { + when { + viewModel.useWebServerFlow -> + viewModel.onWebServerFlowComplete( + code = params["code"], + onAuthFlowError = resultCallback.onMigrationError, + onAuthFlowSuccess = resultCallback.onMigrationSuccess, + loginServer = instanceServer, + tokenMigration = true, + ).join() + + else -> + viewModel.onAuthFlowComplete( + tr = TokenEndpointResponse(params), + onAuthFlowError = resultCallback.onMigrationError, + onAuthFlowSuccess = resultCallback.onMigrationSuccess, + tokenMigration = true, + ) + } + + // Wait until we are completely finished so progress indicator is shown. + finish() + } + } + } + } + + return migrationFinished + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + viewModel.loading.value = true + } + + override fun onPageFinished(view: WebView?, url: String?) { + view?.evaluateJavascript(BACKGROUND_COLOR_JAVASCRIPT) { result -> + makeStatusBarVisible() + validateAndExtractBackgroundColor(result)?.let { color -> + viewModel.dynamicBackgroundColor.value = color + + // This check is inside validateAndExtractBackgroundColor because we only + // want to stop showing the spinner if WebView UI is actually displayed. + if (!viewModel.authFinished.value) { + viewModel.loading.value = false + } + } + } + + super.onPageFinished(view, url) + } + } + + private fun logMigrationError( + resultCallback: MigrationCallbackRegistry.MigrationCallbacks, + error: String, + errorDesc: String?, + e: Throwable?, + ) { + val message = error + (errorDesc?.let { ": $it" } ?: "") + SalesforceSDKLogger.e(TAG, message, e) + resultCallback.onMigrationError(error, errorDesc, e) + finish() + } + + // Ensure Status Bar Icons are readable no matter which OS theme is used. + private fun makeStatusBarVisible() { + val usingDarkTheme = viewModel.dynamicBackgroundTheme.value == DARK + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = usingDarkTheme + } + + companion object { + const val EXTRA_OAUTH_CONFIG = "MIGRATION_OAUTH_CONFIG" + const val EXTRA_ORG_ID = "MIGRATION_ORG_ID" + const val EXTRA_USER_ID = "MIGRATION_USER_ID" + const val EXTRA_CALLBACK_ID = "MIGRATION_CALLBACK" + + const val TAG = "TokenMigrationActivity" + + // Used for mocking the webview in tests. + var webViewFactory = { context: Context -> WebView(context) } + } +} 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 554b72d4b7..004be2f049 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt @@ -500,7 +500,7 @@ private tailrec fun Context.getActivity(): FragmentActivity? = when (this) { } @Composable -private fun Modifier.applyImePaddingConditionally() : Modifier = +internal fun Modifier.applyImePaddingConditionally() : Modifier = // TODO: Remove when min API is > 29 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { windowInsetsPadding(WindowInsets.ime) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt new file mode 100644 index 0000000000..867cbe6c74 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/TokenMigrationView.kt @@ -0,0 +1,51 @@ +package com.salesforce.androidsdk.ui.components + +import android.webkit.WebView +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.ui.LoginViewModel + +@Composable +internal fun TokenMigrationView(webViewFactory: () -> WebView) { + val viewModel: LoginViewModel = + viewModel(factory = SalesforceSDKManager.getInstance().loginViewModelFactory) + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + val alpha: Float by animateFloatAsState( + targetValue = if (viewModel.loading.value) LOADING_ALPHA else VISIBLE_ALPHA, + animationSpec = tween(durationMillis = SLOW_ANIMATION_MS), + ) + + AndroidView( + modifier = Modifier + .background(Color.Transparent) + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .applyImePaddingConditionally() + .graphicsLayer(alpha = alpha), + factory = { webViewFactory.invoke() }, + ) + + if (viewModel.loading.value) { + viewModel.loadingIndicator ?: DefaultLoadingIndicator() + } + } +} \ No newline at end of file diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt new file mode 100644 index 0000000000..7c2bbc4635 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/MigrationCallbackRegistryTest.kt @@ -0,0 +1,203 @@ +/* + * 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.accounts + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for MigrationCallbackRegistry. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class MigrationCallbackRegistryTest { + + @Test + fun register_returnsUniqueKey() { + // Given + val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess, + onMigrationError = onError + ) + + // When + val key1 = MigrationCallbackRegistry.register(callbacks) + val key2 = MigrationCallbackRegistry.register(callbacks) + + // Then + assertNotNull("Key should not be null", key1) + assertNotNull("Key should not be null", key2) + assertNotEquals("Keys should be unique", key1, key2) + + // Cleanup + MigrationCallbackRegistry.consume(key1) + MigrationCallbackRegistry.consume(key2) + } + + @Test + fun consume_returnsCallbacksAndRemovesThem() { + // Given + val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess, + onMigrationError = onError + ) + val key = MigrationCallbackRegistry.register(callbacks) + + // When + val consumedCallbacks = MigrationCallbackRegistry.consume(key) + + // Then + assertNotNull("Consumed callbacks should not be null", consumedCallbacks) + assertEquals("Consumed callbacks should match registered callbacks", callbacks, consumedCallbacks) + } + + @Test + fun consume_returnsNullForNonExistentKey() { + // When + val result = MigrationCallbackRegistry.consume("non-existent-key") + + // Then + assertNull("Should return null for non-existent key", result) + } + + @Test + fun consume_returnsNullOnSecondAttempt() { + // Given + val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess, + onMigrationError = onError + ) + val key = MigrationCallbackRegistry.register(callbacks) + + // When + val firstConsume = MigrationCallbackRegistry.consume(key) + val secondConsume = MigrationCallbackRegistry.consume(key) + + // Then + assertNotNull("First consume should return callbacks", firstConsume) + assertNull("Second consume should return null", secondConsume) + } + + @Test + fun migrationCallbacks_onMigrationSuccess_invokesCallback() { + // Given + val mockAccount: UserAccount = mockk() + val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess, + onMigrationError = onError + ) + + // When + callbacks.onMigrationSuccess(mockAccount) + + // Then + verify(exactly = 1) { onSuccess(mockAccount) } + } + + @Test + fun migrationCallbacks_onMigrationError_invokesCallbackWithAllParams() { + // Given + val error = "Test error" + val errorDesc = "Test error description" + val throwable = RuntimeException("Test exception") + val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess, + onMigrationError = onError + ) + + // When + callbacks.onMigrationError(error, errorDesc, throwable) + + // Then + verify(exactly = 1) { onError(error, errorDesc, throwable) } + } + + @Test + fun migrationCallbacks_onMigrationError_invokesCallbackWithNullParams() { + // Given + val error = "Test error" + val onSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val onError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess, + onMigrationError = onError + ) + + // When + callbacks.onMigrationError(error, null, null) + + // Then + verify(exactly = 1) { onError(error, null, null) } + } + + @Test + fun multipleCallbacks_canBeRegisteredAndConsumedIndependently() { + // Given + val onSuccess1: (UserAccount) -> Unit = mockk(relaxed = true) + val onError1: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks1 = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess1, + onMigrationError = onError1 + ) + + val onSuccess2: (UserAccount) -> Unit = mockk(relaxed = true) + val onError2: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val callbacks2 = MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = onSuccess2, + onMigrationError = onError2 + ) + + // When + val key1 = MigrationCallbackRegistry.register(callbacks1) + val key2 = MigrationCallbackRegistry.register(callbacks2) + + // Then - consume in reverse order + val consumed2 = MigrationCallbackRegistry.consume(key2) + val consumed1 = MigrationCallbackRegistry.consume(key1) + + assertEquals("Callbacks 2 should be consumed correctly", callbacks2, consumed2) + assertEquals("Callbacks 1 should be consumed correctly", callbacks1, consumed1) + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt new file mode 100644 index 0000000000..e6d9f6ab0a --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerMigrateTokenTest.kt @@ -0,0 +1,307 @@ +/* + * 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.accounts + +import android.content.Context +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.ui.TokenMigrationActivity +import com.salesforce.androidsdk.util.SalesforceSDKLogger +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for UserAccountManager.migrateRefreshToken extension function. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class UserAccountManagerMigrateTokenTest { + + private lateinit var mockUserAccountManager: UserAccountManager + private lateinit var mockContext: Context + private lateinit var mockSdkManager: SalesforceSDKManager + private lateinit var mockOAuthConfig: OAuthConfig + + private val onMigrationSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + private val onMigrationError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + + @Before + fun setUp() { + mockUserAccountManager = mockk(relaxed = true) + mockContext = mockk(relaxed = true) + mockSdkManager = mockk(relaxed = true) + mockOAuthConfig = mockk(relaxed = true) + + mockkObject(SalesforceSDKManager) + every { SalesforceSDKManager.getInstance() } returns mockSdkManager + every { mockSdkManager.appContext } returns mockContext + mockkStatic(SalesforceSDKLogger::class) + every { SalesforceSDKLogger.e(any(), any()) } just runs + every { SalesforceSDKLogger.e(any(), any(), any()) } just runs + every { SalesforceSDKLogger.i(any(), any()) } just runs + + mockkObject(MigrationCallbackRegistry) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun migrateRefreshToken_withNullUserAccount_callsOnError() { + // Given + mockkStatic(UserAccountManager::class) + every { UserAccountManager.getInstance() } returns mockUserAccountManager + every { mockUserAccountManager.currentUser } returns null + + // When + mockUserAccountManager.migrateRefreshToken( + userAccount = null, + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + verify(exactly = 1) { onMigrationError.invoke("User account, userId or orgId is null.", null, null) } + verify(exactly = 0) { onMigrationSuccess.invoke(any()) } + verify(exactly = 0) { mockContext.startActivity(any()) } + } + + @Test + fun migrateRefreshToken_withNullUserId_callsOnError() { + // Given + val mockUserAccount: UserAccount = mockk(relaxed = true) + every { mockUserAccount.userId } returns null + every { mockUserAccount.orgId } returns "testOrgId" + + // When + mockUserAccountManager.migrateRefreshToken( + userAccount = mockUserAccount, + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + verify(exactly = 1) { onMigrationError.invoke("User account, userId or orgId is null.", null, null) } + verify(exactly = 0) { onMigrationSuccess.invoke(any()) } + verify(exactly = 0) { mockContext.startActivity(any()) } + } + + @Test + fun migrateRefreshToken_withNullOrgId_callsOnError() { + // Given + val mockUserAccount: UserAccount = mockk(relaxed = true) + every { mockUserAccount.userId } returns "testUserId" + every { mockUserAccount.orgId } returns null + + // When + mockUserAccountManager.migrateRefreshToken( + userAccount = mockUserAccount, + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + verify(exactly = 1) { onMigrationError.invoke("User account, userId or orgId is null.", null, null) } + verify(exactly = 1) { SalesforceSDKLogger.e(any(), "User account, userId or orgId is null.") } + verify(exactly = 0) { onMigrationSuccess.invoke(any()) } + verify(exactly = 0) { mockContext.startActivity(any()) } + } + + @Test + fun migrateRefreshToken_withValidUserAccount_registersCallbackAndStartsActivity() { + // Given + val testUserId = "testUserId123" + val testOrgId = "testOrgId456" + val testCallbackKey = "test-callback-key" + + val mockUserAccount: UserAccount = mockk(relaxed = true) + every { mockUserAccount.userId } returns testUserId + every { mockUserAccount.orgId } returns testOrgId + + every { MigrationCallbackRegistry.register(any()) } returns testCallbackKey + + val intentSlot = slot() + every { mockContext.startActivity(capture(intentSlot)) } just Runs + + // When + mockUserAccountManager.migrateRefreshToken( + userAccount = mockUserAccount, + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + verify(exactly = 1) { MigrationCallbackRegistry.register(any()) } + verify(exactly = 1) { mockContext.startActivity(any()) } + + val capturedIntent = intentSlot.captured + assertNotNull("Intent should not be null", capturedIntent) + assertEquals("Intent should have NEW_TASK flag", + Intent.FLAG_ACTIVITY_NEW_TASK, + capturedIntent.flags and Intent.FLAG_ACTIVITY_NEW_TASK + ) + assertEquals("Intent should have correct org id", + testOrgId, + capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_ORG_ID) + ) + assertEquals("Intent should have correct user id", + testUserId, + capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_USER_ID) + ) + assertEquals("Intent should have correct callback key", + testCallbackKey, + capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID) + ) + } + + @Test + fun migrateRefreshToken_withValidUserAccount_passesOAuthConfigInIntent() { + // Given + val mockUserAccount: UserAccount = mockk(relaxed = true) + every { mockUserAccount.userId } returns "testUserId" + every { mockUserAccount.orgId } returns "testOrgId" + + every { MigrationCallbackRegistry.register(any()) } returns "callback-key" + + val intentSlot = slot() + every { mockContext.startActivity(capture(intentSlot)) } just Runs + + // When + mockUserAccountManager.migrateRefreshToken( + userAccount = mockUserAccount, + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + val capturedIntent = intentSlot.captured + assertTrue("Intent should contain OAuthConfig extra", + capturedIntent.hasExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG) + ) + } + + @Test + fun migrateRefreshToken_registersCorrectCallbacks() { + // Given + val mockUserAccount: UserAccount = mockk(relaxed = true) + every { mockUserAccount.userId } returns "testUserId" + every { mockUserAccount.orgId } returns "testOrgId" + every { mockUserAccount.username } returns "" + every { mockUserAccount.instanceServer } returns "" + + val callbacksSlot = slot() + every { MigrationCallbackRegistry.register(capture(callbacksSlot)) } returns "callback-key" + every { mockContext.startActivity(any()) } just Runs + + // When + mockUserAccountManager.migrateRefreshToken( + userAccount = mockUserAccount, + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + val capturedCallbacks = callbacksSlot.captured + assertNotNull("Callbacks should not be null", capturedCallbacks) + + // Verify success callback is properly wrapped + val testAccount: UserAccount = mockk(relaxed = true) + capturedCallbacks.onMigrationSuccess(testAccount) + verify(exactly = 1) { onMigrationSuccess.invoke(testAccount) } + + // Verify error callback is properly wrapped + val testError = "Test error" + val testDesc = "Test description" + val testThrowable = RuntimeException("Test") + capturedCallbacks.onMigrationError(testError, testDesc, testThrowable) + verify(exactly = 1) { onMigrationError.invoke(testError, testDesc, testThrowable) } + } + + @Test + fun migrateRefreshToken_usesCurrentUserWhenUserAccountNotProvided() { + // Given + val testUserId = "currentUserId" + val testOrgId = "currentOrgId" + + val mockCurrentUser: UserAccount = mockk(relaxed = true) + every { mockCurrentUser.userId } returns testUserId + every { mockCurrentUser.orgId } returns testOrgId + + mockkStatic(UserAccountManager::class) + every { UserAccountManager.getInstance() } returns mockUserAccountManager + every { mockUserAccountManager.currentUser } returns mockCurrentUser + + every { MigrationCallbackRegistry.register(any()) } returns "callback-key" + + val intentSlot = slot() + every { mockContext.startActivity(capture(intentSlot)) } just Runs + + // When - call with default userAccount parameter (null -> uses currentUser) + mockUserAccountManager.migrateRefreshToken( + appConfig = mockOAuthConfig, + onMigrationSuccess = onMigrationSuccess, + onMigrationError = onMigrationError + ) + + // Then + val capturedIntent = intentSlot.captured + assertEquals("Intent should have current user's org id", + testOrgId, + capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_ORG_ID) + ) + assertEquals("Intent should have current user's user id", + testUserId, + capturedIntent.getStringExtra(TokenMigrationActivity.EXTRA_USER_ID) + ) + } +} 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 61bed72135..3dfe496ef4 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -26,6 +26,7 @@ */ package com.salesforce.androidsdk.auth +import android.accounts.Account import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -33,18 +34,28 @@ import androidx.test.platform.app.InstrumentationRegistry import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.accounts.UserAccountBuilder import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.app.Features.FEATURE_BIOMETRIC_AUTH +import com.salesforce.androidsdk.app.Features.FEATURE_SCREEN_LOCK +import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.RuntimeConfig +import com.salesforce.androidsdk.rest.ClientManager +import com.salesforce.androidsdk.security.BiometricAuthenticationManager +import com.salesforce.androidsdk.security.ScreenLockManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.json.JSONObject +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.net.URI /** * Tests for AuthenticationUtilities. @@ -68,8 +79,6 @@ class AuthenticationUtilitiesTest { private val handleScreenLockPolicy: (OAuth2.IdServiceResponse?, UserAccount) -> Unit = mockk() private val handleBiometricAuthPolicy: (OAuth2.IdServiceResponse?, UserAccount) -> Unit = mockk() private val handleDuplicateUserAccount: (UserAccountManager, UserAccount, OAuth2.IdServiceResponse?) -> Unit = mockk() - private var blockIntegrationUser: Boolean = false - private var nativeLogin: Boolean = false @Before fun setUp() { @@ -97,16 +106,18 @@ class AuthenticationUtilitiesTest { every { mockUserAccountManager.createAccount(any()) } returns mockk() every { mockUserAccountManager.switchToUser(any()) } returns Unit every { mockUserAccountManager.sendUserSwitchIntent(any(), any()) } returns Unit + } + @After + fun tearDown() { + unmockkAll() } @Test fun testOnAuthFlowComplete_blockIntegrationUser_shouldCallError() = runTest { - // Given - blockIntegrationUser = true // When - callOnAuthFlowComplete() + callOnAuthFlowComplete(blockIntegrationUser = true) // Then verify { onAuthFlowError.invoke("Error", "Authentication error. Please try again.", null) } @@ -151,7 +162,7 @@ class AuthenticationUtilitiesTest { .accountName(buildAccountName(userIdentity.username, tokenResponse.instanceUrl)) .loginServer("https://login.salesforce.com") .clientId("test_consumer_key") - .nativeLogin(nativeLogin) + .nativeLogin(false) .build() // When @@ -177,7 +188,7 @@ 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 @@ -188,7 +199,7 @@ class AuthenticationUtilitiesTest { .accountName(buildAccountName(null, tokenResponseWithoutIdScope.instanceUrl)) .loginServer("https://login.salesforce.com") .clientId("test_consumer_key") - .nativeLogin(nativeLogin) + .nativeLogin(false) .build() // When @@ -206,12 +217,589 @@ class AuthenticationUtilitiesTest { verify { startMainActivity.invoke() } verify { handleScreenLockPolicy.invoke(null, expectedAccount) } verify { handleBiometricAuthPolicy.invoke(null, expectedAccount) } - + // Verify that fetchUserIdentity was called but returned null coVerify(exactly = 1) { fetchUserIdentity.invoke(tokenResponseWithoutIdScope) } } + @Test + fun testOnAuthFlowComplete_withNativeLogin_shouldCallSuccess() = runTest { + // Given + 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() + .populateFromTokenEndpointResponse(tokenResponseWithoutIdScope) + .populateFromIdServiceResponse(null) // No identity service response + .accountName(buildAccountName(null, tokenResponseWithoutIdScope.instanceUrl)) + .loginServer("https://login.salesforce.com") + .clientId("test_consumer_key") + .nativeLogin(true) // Expect true + .build() + + // When + callOnAuthFlowComplete( + customTokenResponse = tokenResponseWithoutIdScope, + nativeLogin = true, + ) + + // Then + verify(exactly = 0) { onAuthFlowError.invoke(any(), any(), any()) } + verify { onAuthFlowSuccess.invoke(expectedAccount) } + verify { mockUserAccountManager.createAccount(expectedAccount) } + verify { mockUserAccountManager.switchToUser(expectedAccount) } + verify { setAdministratorPreferences.invoke(null, expectedAccount) } + verify { handleDuplicateUserAccount.invoke(mockUserAccountManager, expectedAccount, null) } + verify { addAccount.invoke(expectedAccount) } + verify { updateLoggingPrefs.invoke(expectedAccount) } + verify { startMainActivity.invoke() } + verify { handleScreenLockPolicy.invoke(null, expectedAccount) } + verify { handleBiometricAuthPolicy.invoke(null, expectedAccount) } + + // Verify that fetchUserIdentity was called but returned null + coVerify(exactly = 1) { fetchUserIdentity.invoke(tokenResponseWithoutIdScope) } + } + + + // region Token Migration Tests + + @Test + fun testOnAuthFlowComplete_tokenMigration_shouldNotCallStartMainActivity() = runTest { + // Given + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + + // When - tokenMigration is true + callOnAuthFlowComplete(tokenMigration = true) + + // Then - startMainActivity should NOT be called during token migration + verify(exactly = 0) { startMainActivity.invoke() } + // But onAuthFlowSuccess should still be called + verify(exactly = 1) { onAuthFlowSuccess.invoke(any()) } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_shouldCallSuccess() = runTest { + // Given + val tokenResponse = createTokenEndpointResponse() + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + + // Create the expected UserAccount object + val expectedAccount = UserAccountBuilder.getInstance() + .populateFromTokenEndpointResponse(tokenResponse) + .populateFromIdServiceResponse(userIdentity) + .accountName(buildAccountName(userIdentity.username, tokenResponse.instanceUrl)) + .loginServer("https://login.salesforce.com") + .clientId("test_consumer_key") + .nativeLogin(false) + .build() + + // When - tokenMigration is true + callOnAuthFlowComplete(tokenMigration = true) + + // Then - onAuthFlowSuccess should be called with the account + verify(exactly = 1) { onAuthFlowSuccess.invoke(expectedAccount) } + verify(exactly = 0) { onAuthFlowError.invoke(any(), any(), any()) } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_shouldCreateAccount() = runTest { + // Given + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + + // When - tokenMigration is true + callOnAuthFlowComplete(tokenMigration = true) + + // Then - account creation and user switch should still happen + verify(exactly = 1) { mockUserAccountManager.createAccount(any()) } + verify(exactly = 1) { mockUserAccountManager.switchToUser(any()) } + verify(exactly = 1) { addAccount.invoke(any()) } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_shouldHandleLockPolicies() = runTest { + // Given + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + + // When - tokenMigration is true + callOnAuthFlowComplete(tokenMigration = true) + + // Then - policies should still be handled + verify(exactly = 1) { handleScreenLockPolicy.invoke(any(), any()) } + verify(exactly = 1) { handleBiometricAuthPolicy.invoke(any(), any()) } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_withBlockedIntegrationUser_shouldCallError() = runTest { + // When - tokenMigration is true but user is blocked + callOnAuthFlowComplete( + blockIntegrationUser = true, + tokenMigration = true, + ) + + // Then - error should still be called for blocked users + verify { onAuthFlowError.invoke("Error", "Authentication error. Please try again.", null) } + verify(exactly = 0) { onAuthFlowSuccess.invoke(any()) } + verify(exactly = 0) { startMainActivity.invoke() } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_withManagedAppRequirement_shouldCallError() = runTest { + // Given + val userIdentityWithManagedAppRequirement = createIdServiceResponse( + customPermissions = JSONObject().apply { + put("must_be_managed_app", true) + } + ) + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentityWithManagedAppRequirement + every { mockRuntimeConfig.isManagedApp } returns false + + // When - tokenMigration is true but managed app required + callOnAuthFlowComplete(tokenMigration = true) + + // Then - error should still be called + verify { onAuthFlowError.invoke("Error", "Authentication only allowed from managed device.", null) } + verify(exactly = 0) { onAuthFlowSuccess.invoke(any()) } + verify(exactly = 0) { startMainActivity.invoke() } + } + + // endregion + + // region handleScreenLockPolicy Tests + + @Test + fun testHandleScreenLockPolicy_positiveTimeout_registersFeatureAndStoresPolicy() { + // Given + val mockScreenLockManager = mockk(relaxed = true) + val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager) + + val userIdentity = createIdServiceResponse() + userIdentity.screenLock = true + userIdentity.screenLockTimeout = 10 + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account) + + // Then + verify { mockSdkManager.registerUsedAppFeature(FEATURE_SCREEN_LOCK) } + verify { mockScreenLockManager.storeMobilePolicy(account, enabled = true, 600000) } + } + + @Test + fun testHandleScreenLockPolicy_zeroTimeout_enabledManager_unregistersFeatureAndCleansUp() { + // Given + val mockScreenLockManager = mockk(relaxed = true) { + every { enabled } returns true + } + val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager) + + val userIdentity = createIdServiceResponse() + userIdentity.screenLockTimeout = 0 + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account) + + // Then + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) } + verify { mockScreenLockManager.cleanUp(account) } + } + + @Test + fun testHandleScreenLockPolicy_negativeTimeout_disabledManager_noOp() { + // Given + val mockScreenLockManager = mockk(relaxed = true) { + every { enabled } returns false + } + val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager) + + val userIdentity = createIdServiceResponse() + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleScreenLockPolicy(userIdentity, account) + + // Then + verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) } + verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) } + verify(exactly = 0) { mockScreenLockManager.storeMobilePolicy(any(), any(), any()) } + verify(exactly = 0) { mockScreenLockManager.cleanUp(any()) } + } + + @Test + fun testHandleScreenLockPolicy_nullIdentity_enabledManager_unregistersAndCleansUp() { + // Given + val mockScreenLockManager = mockk(relaxed = true) { + every { enabled } returns true + } + val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager) + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleScreenLockPolicy(null, account) + + // Then + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_SCREEN_LOCK) } + verify { mockScreenLockManager.cleanUp(account) } + } + + @Test + fun testHandleScreenLockPolicy_nullIdentity_disabledManager_noOp() { + // Given + val mockScreenLockManager = mockk(relaxed = true) { + every { enabled } returns false + } + val mockSdkManager = setupMockSdkManager(screenLockManager = mockScreenLockManager) + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleScreenLockPolicy(null, account) + + // Then + verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) } + verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) } + verify(exactly = 0) { mockScreenLockManager.storeMobilePolicy(any(), any(), any()) } + verify(exactly = 0) { mockScreenLockManager.cleanUp(any()) } + } + + // endregion + + // region handleBiometricAuthPolicy Tests + + @Test + fun testHandleBiometricAuthPolicy_biometricEnabled_registersFeatureAndStoresPolicy() { + // Given + val mockBioAuthManager = mockk(relaxed = true) + val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager) + + val userIdentity = createIdServiceResponse() + userIdentity.biometricAuth = true + userIdentity.biometricAuthTimeout = 15 + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account) + + // Then + verify { mockSdkManager.registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) } + verify { mockBioAuthManager.storeMobilePolicy(account, enabled = true, 900000) } + } + + @Test + fun testHandleBiometricAuthPolicy_biometricDisabled_enabledManager_unregistersFeatureAndCleansUp() { + // Given + val mockBioAuthManager = mockk(relaxed = true) { + every { enabled } returns true + } + val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager) + + val userIdentity = createIdServiceResponse() + userIdentity.biometricAuth = false + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account) + + // Then + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) } + verify { mockBioAuthManager.cleanUp(account) } + } + + @Test + fun testHandleBiometricAuthPolicy_biometricDisabled_disabledManager_noOp() { + // Given + val mockBioAuthManager = mockk(relaxed = true) { + every { enabled } returns false + } + val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager) + + val userIdentity = createIdServiceResponse() + userIdentity.biometricAuth = false + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(userIdentity, account) + + // Then + verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) } + verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) } + verify(exactly = 0) { mockBioAuthManager.storeMobilePolicy(any(), any(), any()) } + verify(exactly = 0) { mockBioAuthManager.cleanUp(any()) } + } + + @Test + fun testHandleBiometricAuthPolicy_nullIdentity_enabledManager_unregistersAndCleansUp() { + // Given + val mockBioAuthManager = mockk(relaxed = true) { + every { enabled } returns true + } + val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager) + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(null, account) + + // Then + verify { mockSdkManager.unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH) } + verify { mockBioAuthManager.cleanUp(account) } + } + + @Test + fun testHandleBiometricAuthPolicy_nullIdentity_disabledManager_noOp() { + // Given + val mockBioAuthManager = mockk(relaxed = true) { + every { enabled } returns false + } + val mockSdkManager = setupMockSdkManager(biometricAuthenticationManager = mockBioAuthManager) + + val account = mockk() + + // When + com.salesforce.androidsdk.auth.handleBiometricAuthPolicy(null, account) + + // Then + verify(exactly = 0) { mockSdkManager.registerUsedAppFeature(any()) } + verify(exactly = 0) { mockSdkManager.unregisterUsedAppFeature(any()) } + verify(exactly = 0) { mockBioAuthManager.storeMobilePolicy(any(), any(), any()) } + verify(exactly = 0) { mockBioAuthManager.cleanUp(any()) } + } + + // endregion + + // region handleDuplicateUserAccount Tests + + @Test + fun testHandleDuplicateUserAccount_nullAuthenticatedUsers_noOp() { + // Given + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns null + } + val account = buildTestUserAccount() + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + + // Then + verify(exactly = 0) { mockUam.clearCachedCurrentUser() } + } + + @Test + fun testHandleDuplicateUserAccount_noDuplicate_noRemoval() { + // Given + val otherUser = buildTestUserAccount(userId = "005OTHER") + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(otherUser) + } + val account = buildTestUserAccount() + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + + // Then + verify(exactly = 0) { mockUam.clearCachedCurrentUser() } + verify(exactly = 0) { mockUam.buildAccount(any()) } + } + + @Test + fun testHandleDuplicateUserAccount_duplicateFound_sameRefreshToken_removesAccountOnly() { + // Given + val mockClientManager = mockk(relaxed = true) + setupMockSdkManager(clientManager = mockClientManager) + + val duplicateUser = buildTestUserAccount(refreshToken = "same_token") + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(duplicateUser) + } + val account = buildTestUserAccount(refreshToken = "same_token") + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + + // Then + verify { mockUam.clearCachedCurrentUser() } + } + + @Test + fun testHandleDuplicateUserAccount_duplicateFound_differentRefreshToken_revokesToken() { + // Given + val mockClientManager = mockk(relaxed = true) + setupMockSdkManager(clientManager = mockClientManager) + val mockRevokeRefreshToken = mockk<(HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit>(relaxed = true) + + val duplicateUser = buildTestUserAccount( + refreshToken = "old_token", + instanceServer = "https://test.salesforce.com" + ) + val mockAccount = mockk() + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(duplicateUser) + every { buildAccount(duplicateUser) } returns mockAccount + } + val account = buildTestUserAccount(refreshToken = "new_token") + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null, mockRevokeRefreshToken) + Thread.sleep(500) + + // Then + verify { mockUam.clearCachedCurrentUser() } + verify { + mockRevokeRefreshToken.invoke( + any(), + any(), + eq("old_token"), + eq(OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED) + ) + } + } + + @Test + fun testHandleDuplicateUserAccount_duplicateFound_differentRefreshToken_biometricEnabled_unlocks() { + // Given + val mockBioAuthManager = mockk(relaxed = true) + val mockClientManager = mockk(relaxed = true) + val mockSdkManager = setupMockSdkManager( + biometricAuthenticationManager = mockBioAuthManager, + clientManager = mockClientManager + ) + setupBiometricEnabledPrefs(mockSdkManager) + val mockRevokeRefreshToken = mockk<(HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit>(relaxed = true) + + val duplicateUser = buildTestUserAccount( + refreshToken = "old_token", + instanceServer = "https://test.salesforce.com" + ) + val mockAccount = mockk() + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(duplicateUser) + every { buildAccount(duplicateUser) } returns mockAccount + } + val account = buildTestUserAccount(refreshToken = "new_token") + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null, mockRevokeRefreshToken) + Thread.sleep(500) + + // Then + verify { mockBioAuthManager.onUnlock() } + verify { + mockRevokeRefreshToken.invoke( + any(), + any(), + eq("old_token"), + eq(OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED) + ) + } + } + + @Test + fun testHandleDuplicateUserAccount_biometricIdentity_signsOutExistingBiometricUsers() { + // Given + val mockSdkManager = setupMockSdkManager() + setupBiometricEnabledPrefs(mockSdkManager) + + val existingBioUser = buildTestUserAccount(userId = "005BIO_USER") + + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(existingBioUser) + } + val account = buildTestUserAccount() + val userIdentity = createIdServiceResponse() + userIdentity.biometricAuth = true + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, userIdentity) + + // Then + verify { mockUam.signoutUser(existingBioUser, null, false, OAuth2.LogoutReason.UNEXPECTED) } + } + + @Test + fun testHandleDuplicateUserAccount_nullIdentity_skipsBiometricSignout() { + // Given + val mockSdkManager = setupMockSdkManager() + setupBiometricEnabledPrefs(mockSdkManager) + + val existingBioUser = buildTestUserAccount(userId = "005BIO_USER") + + val mockUam = mockk(relaxed = true) { + every { authenticatedUsers } returns mutableListOf(existingBioUser) + } + val account = buildTestUserAccount() + + // When + com.salesforce.androidsdk.auth.handleDuplicateUserAccount(mockUam, account, null) + + // Then + verify(exactly = 0) { mockUam.signoutUser(any(), any(), any(), any()) } + } + + // endregion + + private fun buildTestUserAccount( + userId: String = "005000000000000AAA", + orgId: String = "00D000000000000EAA", + refreshToken: String = "test_refresh_token", + instanceServer: String = "https://test.salesforce.com" + ): UserAccount { + return UserAccountBuilder.getInstance() + .userId(userId) + .orgId(orgId) + .refreshToken(refreshToken) + .instanceServer(instanceServer) + .authToken("test_auth_token") + .loginServer("https://login.salesforce.com") + .idUrl("https://test.salesforce.com/id/$orgId/$userId") + .accountName("test_account") + .build() + } + + private fun setupBiometricEnabledPrefs(mockSdkManager: SalesforceSDKManager) { + val mockPrefs = mockk(relaxed = true) { + every { getBoolean("bio_auth_enabled", false) } returns true + } + val mockContext = mockk(relaxed = true) { + every { getSharedPreferences(any(), any()) } returns mockPrefs + } + every { mockSdkManager.appContext } returns mockContext + } + + private fun setupMockSdkManager( + screenLockManager: ScreenLockManager? = null, + biometricAuthenticationManager: BiometricAuthenticationManager? = null, + clientManager: ClientManager? = null + ): SalesforceSDKManager { + mockkObject(SalesforceSDKManager) + val mockSdkManager = mockk(relaxed = true) + every { SalesforceSDKManager.getInstance() } returns mockSdkManager + screenLockManager?.let { every { mockSdkManager.screenLockManager } returns it } + biometricAuthenticationManager?.let { every { mockSdkManager.biometricAuthenticationManager } returns it } + clientManager?.let { every { mockSdkManager.clientManager } returns it } + return mockSdkManager + } - private suspend fun callOnAuthFlowComplete(customTokenResponse: OAuth2.TokenEndpointResponse? = null) { + private suspend fun callOnAuthFlowComplete( + customTokenResponse: OAuth2.TokenEndpointResponse? = null, + nativeLogin: Boolean = false, + tokenMigration: Boolean = false, + blockIntegrationUser: Boolean = false, + ) { onAuthFlowComplete( tokenResponse = customTokenResponse ?: createTokenEndpointResponse(), loginServer = "https://login.salesforce.com", @@ -220,6 +808,7 @@ class AuthenticationUtilitiesTest { onAuthFlowSuccess = onAuthFlowSuccess, buildAccountName = buildAccountName, nativeLogin = nativeLogin, + tokenMigration = tokenMigration, context = testContext, userAccountManager = mockUserAccountManager, blockIntegrationUser = blockIntegrationUser, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index 8d3e4f78f6..2458e8b546 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -38,10 +38,12 @@ import com.salesforce.androidsdk.ui.LoginViewModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.spyk import io.mockk.unmockkAll -import io.mockk.verify import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertNotNull @@ -50,7 +52,6 @@ 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. @@ -73,8 +74,7 @@ class LoginViewModelMockTest { mockCookieManager = mockk(relaxed = true) every { CookieManager.getInstance() } returns mockCookieManager - // Mock OAuth2 and AuthenticationUtilities - mockkStatic(OAuth2::class) + // Mock AuthenticationUtilities mockkStatic("com.salesforce.androidsdk.auth.AuthenticationUtilitiesKt") // Create view model after mocking @@ -109,6 +109,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -140,13 +141,14 @@ class LoginViewModelMockTest { // Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters coVerify { onAuthFlowComplete( - tokenResponse = eq(mockTokenResponse), - loginServer = eq(testServer), - consumerKey = eq(bootConfig.remoteAccessConsumerKey), + tokenResponse = mockTokenResponse, + loginServer = testServer, + consumerKey = bootConfig.remoteAccessConsumerKey, onAuthFlowError = any(), onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = false, context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -176,6 +178,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -213,6 +216,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = false, context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -242,6 +246,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -289,6 +294,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -318,13 +324,14 @@ class LoginViewModelMockTest { // Verify empty string is used when selectedServer is null coVerify { onAuthFlowComplete( - tokenResponse = eq(mockTokenResponse), - loginServer = eq(""), + tokenResponse = mockTokenResponse, + loginServer = "", consumerKey = any(), onAuthFlowError = any(), onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = false, context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -342,77 +349,166 @@ class LoginViewModelMockTest { } @Test - fun doCodeExchange_CallsExchangeCode_WithCorrectParameters() = runBlocking { + fun onWebServerFlowComplete_CallsDoCodeExchange_WithCorrectParameters() = runBlocking { 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 - 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 + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) + } returns Result.success(Unit) + // Set up the view model state - viewModel.selectedServer.value = testServer + spyViewModel.selectedServer.value = testServer Thread.sleep(100) - // Call the method under test via onWebServerFlowComplete - viewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) + // Call the method under test + spyViewModel.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, + // Verify doCodeExchange was called with correct parameters + coVerify { + spyViewModel.doCodeExchange( + testCode, + mockOnError, + mockOnSuccess, + loginServer = null, + tokenMigration = false, + ) + } + } + + @Test + fun onTokenMigration_CallsDoCodeExchange_WithCorrectParameters() = runBlocking { + val testServer = "https://test.salesforce.com" + val testCode = "test_auth_code_123" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val migrationLoginServer = "migration_login_server" + + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) + + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) + } returns Result.success(Unit) + + // Set up the view model state + spyViewModel.selectedServer.value = testServer + Thread.sleep(100) + + // Call the method under test + spyViewModel.onWebServerFlowComplete( + testCode, + mockOnError, + mockOnSuccess, + migrationLoginServer, + tokenMigration = true, + ) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify doCodeExchange was called with correct parameters + coVerify { + spyViewModel.doCodeExchange( testCode, - viewModel.codeVerifier, - bootConfig.oauthRedirectURI, + mockOnError, + mockOnSuccess, + loginServer = migrationLoginServer, + tokenMigration = true, ) } } @Test - fun doCodeExchange_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking { + fun onWebServerFlowComplete_WithFrontDoorBridge_UsesCorrectServerAndVerifier() = runBlocking { 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 + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) + + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) + } returns Result.success(Unit) + + // Set up front door bridge + spyViewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier) + Thread.sleep(100) + + // Call the method under test + spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify doCodeExchange was called with null loginServer and false tokenMigration + coVerify { + spyViewModel.doCodeExchange( + testCode, + mockOnError, + mockOnSuccess, + loginServer = null, + tokenMigration = false, + ) + } + } + + @Test + fun onWebServerFlowComplete_WithNullCode_CallsDoCodeExchangeWithNull() = runBlocking { + val testServer = "https://test.salesforce.com" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) + + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) + } returns Result.success(Unit) + + // Set up the view model state + spyViewModel.selectedServer.value = testServer + Thread.sleep(100) + + // Call with null code + spyViewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify doCodeExchange was called with null code, null loginServer, and false tokenMigration + coVerify { + spyViewModel.doCodeExchange( + null, + mockOnError, + mockOnSuccess, + loginServer = null, + tokenMigration = false, + ) + } + } + + // region Token Migration Tests + + @Test + fun onAuthFlowComplete_WithTokenMigration_PassesCorrectParameters() = runBlocking { + // Mock the AuthenticationUtilities.onAuthFlowComplete function coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -422,6 +518,7 @@ class LoginViewModelMockTest { onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -436,59 +533,29 @@ class LoginViewModelMockTest { 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 { - 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 - coEvery { + + // Set up the view model state + viewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + // Call the method under test with tokenMigration = true + viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess, tokenMigration = true) + + // Verify tokenMigration parameter is passed as true + coVerify { onAuthFlowComplete( - tokenResponse = any(), + tokenResponse = mockTokenResponse, loginServer = any(), consumerKey = any(), onAuthFlowError = any(), onAuthFlowSuccess = any(), buildAccountName = any(), nativeLogin = any(), + tokenMigration = true, context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -502,40 +569,148 @@ class LoginViewModelMockTest { handleBiometricAuthPolicy = any(), handleDuplicateUserAccount = any(), ) - } returns Unit + } + } + + @Test + fun onWebServerFlowComplete_WithTokenMigration_PassesCorrectParameters() = runBlocking { + val customLoginServer = "https://custom.salesforce.com" + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) - // Mock exchangeCode + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) + } returns Result.success(Unit) + + // Set up the view model state with different server + spyViewModel.selectedServer.value = "https://different.salesforce.com" + Thread.sleep(100) + + // Call the method under test with custom loginServer and tokenMigration + spyViewModel.onWebServerFlowComplete( + testCode, + mockOnError, + mockOnSuccess, + loginServer = customLoginServer, + tokenMigration = true + ) + + // Give time for the coroutine to execute + Thread.sleep(200) + + // Verify doCodeExchange was called with the correct loginServer and tokenMigration + coVerify { + spyViewModel.doCodeExchange( + testCode, + mockOnError, + mockOnSuccess, + loginServer = customLoginServer, + tokenMigration = true, + ) + } + } + + @Test + fun doCodeExchange_UtilizesOAuth2_AndFinishesAuth() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true) + + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode( - any(), - any(), - any(), - any(), - any(), - any(), + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } returns mockTokenResponse + + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } just runs + + // Set up required state + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + // Call function under test + spyViewModel.doCodeExchange( + testCode, + mockOnError, + mockOnSuccess, + ) + + // Give time for the coroutine to execute + Thread.sleep(200) + + coVerify { + spyViewModel.onAuthFlowComplete( + mockTokenResponse, + mockOnError, + mockOnSuccess, + tokenMigration = false, + loginServer = "https://test.salesforce.com", ) + } + } + + @Test + fun doCodeExchange_TokenMigration_PassesCorrectValues() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true) + val migrationServer = "migration_server" + + // Create a spy of viewModel to verify and mock doCodeExchange + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse - - // Set up the view model state - viewModel.selectedServer.value = testServer + + // Mock doCodeExchange to prevent actual execution + coEvery { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } just runs + + // Set up required state + spyViewModel.selectedServer.value = "https://test.salesforce.com" Thread.sleep(100) - - // Call with null code - viewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess) - + + // Call function under test + spyViewModel.doCodeExchange( + testCode, + mockOnError, + mockOnSuccess, + migrationServer, + tokenMigration = true, + ) + // 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, + + coVerify { + spyViewModel.onAuthFlowComplete( + mockTokenResponse, + mockOnError, + mockOnSuccess, + tokenMigration = true, + loginServer = migrationServer, ) } } + + // endregion } 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 28f848e5e6..1f3ee9540f 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -405,6 +405,63 @@ class LoginViewModelTest { ) } + @Test + fun getAuthorizationUrl_UsesMigrationConfig_OverAppConfigForLoginHost() { + val sdkManagerMock = mockk(relaxed = false) + val appConfigConsumerKey = "app_config_key_should_not_be_used" + val appConfigRedirectUri = "appconfig://should_not_be_used" + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> + OAuthConfig( + consumerKey = appConfigConsumerKey, + redirectUri = appConfigRedirectUri, + scopes = listOf("api"), + ) + } + val debugConsumerKey = "debug_override_key_789" + val debugRedirectUri = "debug://redirect" + val debugScopes = listOf("api", "debug_scope") + every { sdkManagerMock.debugOverrideAppConfig } returns OAuthConfig( + consumerKey = debugConsumerKey, + redirectUri = debugRedirectUri, + scopes = debugScopes, + ) + val migrationConsumerKey = "migration_override_key_789" + val migrationRedirectUri = "migration://redirect" + val migrationScopes = listOf("api", "migration_scope") + + // Verify the URL contains the app config values, not the debug override config values + val loginUrl = runBlocking { + viewModel.getAuthorizationUrl( + server = "test.salesforce.com", + sdkManagerMock, + migrationOAuthConfig = OAuthConfig( + migrationConsumerKey, + migrationRedirectUri, + migrationScopes, + ) + ) + } + assertFalse( + "URL should not contain debug override consumer key", + loginUrl.contains(debugConsumerKey) + ) + assertFalse( + "URL should not contain debug override redirect URI", + loginUrl.contains("redirect_uri=debug://redirect") + ) + assertFalse("URL should not contain debug scope", loginUrl.contains("debug_scope")) + + // Verify migration config values are in the URL + assertTrue( + "URL should contain migration consumer key", + loginUrl.contains(migrationConsumerKey) + ) + migrationScopes.forEach { scope -> + assertTrue(loginUrl.contains(scope)) + } + } + @Test fun getAuthorizationUrl_UsesServerSpecificConfig_FromAppConfigForLoginHost() { val sdkManager = SalesforceSDKManager.getInstance() 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 42816fd788..8da4abc8bd 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/RestClientTest.java @@ -26,6 +26,8 @@ */ package com.salesforce.androidsdk.rest; +import static com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -368,7 +370,7 @@ public void testGetRawResponse() throws Exception { public void testGetSingleAccess() throws Exception { RestResponse response = restClient.sendSync(RestRequest.getRequestForSingleAccess("abc/def")); checkResponse(response, HttpURLConnection.HTTP_OK, false); - checkKeys(response.asJSONObject(), "frontdoor_uri"); + checkKeys(response.asJSONObject(), FRONTDOOR_URL_KEY); } /** diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index 3dd8081d39..8c53867860 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -165,7 +165,7 @@ class LoginActivityTest { val observer = activity.BrowserCustomTabUrlObserver(activity) observer.onChanged(exampleUrl) - verify(exactly = -1) { + verify { activity.startBrowserCustomTabAuthorization( match { it == exampleUrl }, match { it == activityResultLauncher } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt new file mode 100644 index 0000000000..5ce42e8ecb --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationActivityTest.kt @@ -0,0 +1,393 @@ +/* + * 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.ui + +import android.content.Intent +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.test.core.app.ActivityScenario.launch +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.analytics.AnalyticsPublishingWorker +import com.salesforce.androidsdk.analytics.logger.SalesforceLogger +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY +import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.rest.RestClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +internal const val VALID_ORG = "valid-org" +internal const val VALID_USER = "valid-user" +internal const val INVALID_ORG = "invalid-org" +internal const val INVALID_USER = "invalid-user" + +/** + * Tests for TokenMigrationActivity using ActivityScenario. + */ +@RunWith(AndroidJUnit4::class) +class TokenMigrationActivityTest { + + val mockOAuthConfig = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "testapp://oauth/callback", + scopes = listOf("api", "refresh_token"), + ) + + private lateinit var mockUserAccountManager: UserAccountManager + private lateinit var mockSdkManager: SalesforceSDKManager + private lateinit var mockRestClient: RestClient + private lateinit var mockUser: UserAccount + + @Before + fun setUp() { + // Mock SalesforceLogger.getLogger to prevent readLoggerPrefs from being called + val mockLogger: SalesforceLogger = mockk(relaxed = true) + mockkStatic(SalesforceLogger::class) + every { SalesforceLogger.getLogger(any(), any()) } returns mockLogger + every { SalesforceLogger.getLogger(any(), any(), any()) } returns mockLogger + + // Reset logger prefs as backup + SalesforceLogger.flushComponents() + SalesforceLogger.resetLoggerPrefs(getApplicationContext()) + + mockUserAccountManager = mockk(relaxed = true) + mockSdkManager = mockk(relaxed = true) + mockRestClient = mockk(relaxed = true) + mockUser = mockk(relaxed = true) + + // Mock user properties needed for getAuthorizationUrl + every { mockUser.instanceServer } returns "https://test.salesforce.com" + + mockkStatic(UserAccountManager::class) + every { UserAccountManager.getInstance() } returns mockUserAccountManager + every { + mockUserAccountManager.getUserFromOrgAndUserId(VALID_ORG, VALID_USER) + } returns mockUser + every { + mockUserAccountManager.getUserFromOrgAndUserId(INVALID_ORG, INVALID_USER) + } returns null + + mockkObject(SalesforceSDKManager) + mockkObject(SalesforceSDKManager.Companion) + every { SalesforceSDKManager.getInstance() } returns mockSdkManager + every { mockSdkManager.appContext } returns getApplicationContext() + every { mockSdkManager.clientManager.peekRestClient(any()) } returns mockRestClient + every { mockSdkManager.useHybridAuthentication } returns false + every { mockSdkManager.userAgent } returns "MockUserAgent" + + // Default mock for sendSync to prevent hanging - tests can override this + val mockResponse = mockk(relaxed = true) + every { mockResponse.isSuccess } returns true + every { mockResponse.asString() } returns """{"$FRONTDOOR_URL_KEY": "https://test.salesforce.com/frontdoor"}""" + every { mockRestClient.sendSync(any()) } returns mockResponse + + // Mock AnalyticsPublishingWorker to prevent NPE during activity lifecycle + mockkObject(AnalyticsPublishingWorker.Companion) + every { AnalyticsPublishingWorker.enqueueAnalyticsPublishWorkRequest(any(), any()) } returns null + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun onCreate_withMissingCallbackId_finishesActivity() { + // Given - Intent without callback ID + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java) + // No EXTRA_CALLBACK_ID set + + // When + launch(intent).use { scenario -> + // Then - Activity should finish immediately + assertEquals( + "Activity should be destroyed when callback ID is missing", + DESTROYED, + scenario.state, + ) + } + } + + @Test + fun onCreate_withInvalidCallbackId_finishesActivity() { + // Given - Intent with callback ID that's not in the registry + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, "invalid-callback-id") + } + + // When + launch(intent).use { scenario -> + // Then - Activity should finish because callback is not in registry + assertEquals( + "Activity should be destroyed when callback ID is invalid", + DESTROYED, + scenario.state, + ) + } + } + + @Test + fun onCreate_withMissingOAuthConfig_callsErrorCallbackAndFinishes() { + // Given + var errorCalled = false + var errorMessage: String? = null + val latch = CountDownLatch(1) + + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { error, _, _ -> + errorCalled = true + errorMessage = error + latch.countDown() + }, + ) + ) + + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + // No EXTRA_OAUTH_CONFIG set + } + + // When + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(ERROR_PARSE_OAUTH_CONFIG, errorMessage) + assertEquals(DESTROYED, scenario.state) + } + } + + @Test + fun onCreate_withMissingOrgId_callsErrorCallbackAndFinishes() { + // Given + var errorCalled = false + val latch = CountDownLatch(1) + + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { _, _, _ -> + errorCalled = true + latch.countDown() + }, + ) + ) + + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER) + // No EXTRA_ORG_ID set + } + + // When + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(DESTROYED, scenario.state) + } + } + + @Test + fun onCreate_withMissingUserId_callsErrorCallbackAndFinishes() { + // Given + var errorCalled = false + val latch = CountDownLatch(1) + + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { _, _, _ -> + errorCalled = true + latch.countDown() + }, + ) + ) + + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig) + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG) + // No EXTRA_USER_ID set + } + + // When + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(DESTROYED, scenario.state) + } + } + + @Test + fun onCreate_withUserNotFound_callsErrorCallbackAndFinishes() { + // Given + var errorCalled = false + var errorMessage: String? = null + val latch = CountDownLatch(1) + + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { error, _, _ -> + errorCalled = true + errorMessage = error + latch.countDown() + }, + ) + ) + + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig) + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, INVALID_ORG) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, INVALID_USER) + } + + // When + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(ERROR_BUILD_USER_ACCOUNT, errorMessage) + assertEquals(DESTROYED, scenario.state) + } + } + + @Test + fun onCreate_withClientException_callsErrorCallbackAndFinishes() { + // Given + var errorCalled = false + var errorMessage: String? = null + val latch = CountDownLatch(1) + + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { error, _, _ -> + errorCalled = true + errorMessage = error + latch.countDown() + }, + ) + ) + + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig) + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER) + } + + // Throw exception when getting client + every { + mockSdkManager.clientManager.peekRestClient(any()) + } throws RuntimeException("Account not found") + + // When + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(ERROR_BUILD_REST_CLIENT, errorMessage) + assertEquals(DESTROYED, scenario.state) + } + } + + @Test + fun onCreate_withNullFrontDoorUrl_callsErrorCallbackAndFinishes() { + // Given + var errorCalled = false + var errorMessage: String? = null + var errorDesc: String? = null + val latch = CountDownLatch(1) + + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { error, desc, _ -> + errorCalled = true + errorMessage = error + errorDesc = desc + latch.countDown() + }, + ) + ) + + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig) + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER) + } + + // Mock response without frontdoor_uri key + every { mockRestClient.sendSync(any()) } returns mockk(relaxed = true) { + every { isSuccess } returns true + every { asString() } returns "{}" + } + + // Mock loginViewModelFactory + every { mockSdkManager.loginViewModelFactory } returns object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + return mockk(relaxed = true) as T + } + } + + // When + launch(intent).use { scenario -> + // Then + latch.await(500, TimeUnit.MILLISECONDS) + assertTrue("Error callback should be called", errorCalled) + assertEquals(ERROR_SINGLE_ACCESS_FAILED, errorMessage) + assertEquals(ERROR_TOKEN_INVALID_DESC, errorDesc) + assertEquals(DESTROYED, scenario.state) + } + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt new file mode 100644 index 0000000000..1984614898 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationViewActivityTest.kt @@ -0,0 +1,214 @@ +/* + * 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.ui + +import android.Manifest +import android.os.Build +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color.Companion.Red +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.test.rule.GrantPermissionRule +import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.ui.components.TokenMigrationView +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class TokenMigrationViewActivityTest { + + @get:Rule + val androidComposeTestRule = createAndroidComposeRule() + + // TODO: Remove if when min SDK version is 33 + @get:Rule + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + + private lateinit var savedFactory: ViewModelProvider.Factory + private lateinit var mockViewModel: LoginViewModel + + @Before + fun setUp() { + savedFactory = SalesforceSDKManager.getInstance().loginViewModelFactory + + mockViewModel = mockk(relaxed = true) { + every { loading } returns mutableStateOf(false) + every { loadingIndicator } returns null + every { dynamicBackgroundColor } returns mutableStateOf(Transparent) + every { dynamicBackgroundTheme } returns mutableStateOf(SalesforceSDKManager.Theme.LIGHT) + } + + SalesforceSDKManager.getInstance().loginViewModelFactory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras, + ): T = mockViewModel as T + } + } + + @After + fun tearDown() { + SalesforceSDKManager.getInstance().loginViewModelFactory = savedFactory + unmockkAll() + } + + @Test + fun tokenMigrationView_NotLoading_HidesLoadingIndicator() { + every { mockViewModel.loading } returns mutableStateOf(false) + + androidComposeTestRule.setContent { + val context = LocalContext.current + TokenMigrationView(webViewFactory = { WebView(context) }) + } + + val loadingIndicator = androidComposeTestRule.onNodeWithContentDescription( + androidComposeTestRule.activity.getString(R.string.sf__loading_indicator) + ) + + loadingIndicator.assertIsNotDisplayed() + } + + @Test + fun tokenMigrationView_Loading_ShowsLoadingIndicator() { + every { mockViewModel.loading } returns mutableStateOf(true) + + androidComposeTestRule.setContent { + val context = LocalContext.current + TokenMigrationView(webViewFactory = { WebView(context) }) + } + + val loadingIndicator = androidComposeTestRule.onNodeWithContentDescription( + androidComposeTestRule.activity.getString(R.string.sf__loading_indicator) + ) + + loadingIndicator.assertIsDisplayed() + } + + @Test + fun tokenMigrationView_DefaultBackgroundColor_IsTransparent() { + val backgroundColor = mutableStateOf(Transparent) + every { mockViewModel.dynamicBackgroundColor } returns backgroundColor + + androidComposeTestRule.setContent { + val context = LocalContext.current + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + background = backgroundColor.value + ) + ) { + TokenMigrationView(webViewFactory = { WebView(context) }) + } + } + + assertEquals( + "Default background color should be Transparent", + Transparent, + backgroundColor.value, + ) + } + + @Test + fun tokenMigrationView_BackgroundColorUpdates_ReflectsNewColor() { + val backgroundColor = mutableStateOf(Transparent) + every { mockViewModel.dynamicBackgroundColor } returns backgroundColor + + androidComposeTestRule.setContent { + val context = LocalContext.current + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + background = backgroundColor.value + ) + ) { + TokenMigrationView(webViewFactory = { WebView(context) }) + } + } + + androidComposeTestRule.runOnIdle { + backgroundColor.value = White + } + + androidComposeTestRule.runOnIdle { + assertEquals( + "Background color should update to White", + White, + backgroundColor.value, + ) + } + } + + @Test + fun tokenMigrationView_BackgroundColorUpdatesToRed_ReflectsNewColor() { + val backgroundColor = mutableStateOf(Transparent) + every { mockViewModel.dynamicBackgroundColor } returns backgroundColor + + androidComposeTestRule.setContent { + val context = LocalContext.current + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + background = backgroundColor.value + ) + ) { + TokenMigrationView(webViewFactory = { WebView(context) }) + } + } + + androidComposeTestRule.runOnIdle { + backgroundColor.value = Red + } + + androidComposeTestRule.runOnIdle { + assertEquals( + "Background color should update to Red", + Red, + backgroundColor.value, + ) + } + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt new file mode 100644 index 0000000000..c26b720983 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/TokenMigrationWebViewTest.kt @@ -0,0 +1,484 @@ +package com.salesforce.androidsdk.ui + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.salesforce.androidsdk.accounts.MigrationCallbackRegistry +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.config.OAuthConfig +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class TokenMigrationWebViewTest { + + val mockOAuthConfig = OAuthConfig( + consumerKey = "test_consumer_key", + redirectUri = "testapp://oauth/callback", + scopes = listOf("api", "refresh_token"), + ) + + private lateinit var savedFactory: ViewModelProvider.Factory + private lateinit var mockViewModel: LoginViewModel + private lateinit var mockWebView: WebView + private var testActivity: TokenMigrationActivity? = null + + + @Before + fun setUp() { + // Save the real loginViewModelFactory so it can be restored in tearDown + savedFactory = SalesforceSDKManager.getInstance().loginViewModelFactory + + // Mock loginViewModelFactory to return a mock LoginViewModel + mockViewModel = mockk(relaxed = true) { + every { dynamicBackgroundColor } returns mutableStateOf(Color.White) + every { loading } returns mutableStateOf(false) + every { authFinished } returns mutableStateOf(false) + every { loadingIndicator } returns null + every { oAuthConfig } returns mockOAuthConfig + every { useWebServerFlow } returns true + } + + // Wire up the factory so the activity's `by viewModels` delegate returns mockViewModel + SalesforceSDKManager.getInstance().loginViewModelFactory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras, + ): T = mockViewModel as T + } + + TokenMigrationActivity.webViewFactory = { context -> TestWebView(context) } + + mockWebView = mockk(relaxed = true) { + every { loadUrl(any()) } just runs + every { parent } returns null + } + } + + @After + fun tearDown() { + testActivity?.let { activity -> + if (!activity.isFinishing && !activity.isDestroyed) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + activity.finish() + } + } + } + testActivity = null + // Restore the real loginViewModelFactory + SalesforceSDKManager.getInstance().loginViewModelFactory = savedFactory + unmockkAll() // cleans up any mockk() instances created in tests + } + + /** + * A WebView subclass that disables network loading and allows state verification. + */ + open class TestWebView(context: Context) : WebView(context) { + var onLoadUrlCallback: (() -> Unit)? = null + + override fun loadUrl(url: String) { + // No-op to prevent network loading + onLoadUrlCallback?.invoke() + } + + override fun loadUrl(url: String, additionalHttpHeaders: MutableMap) { + // No-op + onLoadUrlCallback?.invoke() + } + } + + // region shouldOverrideUrlLoading Tests + + @Test + fun shouldOverrideUrlLoading_returnsFalseForNonCallbackUrl() { + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com") + val mockRequest = createMockWebResourceRequest("https://login.salesforce.com/somepage") + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + assertFalse( + "Non-callback URL should not be overridden", + clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest) + ) + + verify(exactly = 0) { + mockViewModel.onWebServerFlowComplete(any(), any(), any(), any(), any()) + } + coVerify(exactly = 0) { + mockViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } + } + } + + @Test + fun shouldOverrideUrlLoading_returnsTrueForCallbackUrl_webServerFlow() { + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "instanceServer") + + every { mockViewModel.useWebServerFlow } returns true + + val mockRequest = createMockWebResourceRequest("testapp://oauth/callback?code=test_code") + InstrumentationRegistry.getInstrumentation().runOnMainSync { + assertTrue( + "Callback URL should be overridden because migration is finished", + clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest) + ) + + verify { + mockViewModel.onWebServerFlowComplete( + code = "test_code", + onAuthFlowSuccess = mockResultCallback.onMigrationSuccess, + onAuthFlowError = mockResultCallback.onMigrationError, + loginServer = "instanceServer", + tokenMigration = true, + ) + } + + coVerify(exactly = 0) { + mockViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } + } + } + + @Test + fun shouldOverrideUrlLoading_returnsTrueForCallbackUrl_userAgentFlow() { + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "instanceServer") + + every { mockViewModel.useWebServerFlow } returns false + + val mockRequest = createMockWebResourceRequest("testapp://oauth/callback?code=test_code") + InstrumentationRegistry.getInstrumentation().runOnMainSync { + assertTrue( + "Callback URL should be overridden because migration is finished", + clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest) + ) + coVerify { + mockViewModel.onAuthFlowComplete( + tr = any(), + onAuthFlowSuccess = mockResultCallback.onMigrationSuccess, + onAuthFlowError = mockResultCallback.onMigrationError, + tokenMigration = true, + ) + } + + verify(exactly = 0) { + mockViewModel.onWebServerFlowComplete(any(), any(), any(), any(), any()) + } + } + } + + @Test + fun shouldOverrideUrlLoading_callsErrorCallbackOnError() { + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com") + + val mockRequest = createMockWebResourceRequest( + "testapp://oauth/callback?error=access_denied&error_description=User%20denied%20access" + ) + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest) + } + + verify { + mockResultCallback.onMigrationError("access_denied", "User denied access", null) + } + + + } + + @Test + fun shouldOverrideUrlLoading_normalizesTripleSlashes() { + every { mockViewModel.oAuthConfig } returns OAuthConfig( + consumerKey = "test", + redirectUri = "testapp:///oauth/callback", + scopes = listOf("api"), + ) + + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "instanceServer") + + // URL with triple slash should still match after normalization + val mockRequest = createMockWebResourceRequest("testapp:///oauth/callback?code=test_code") + InstrumentationRegistry.getInstrumentation().runOnMainSync { + assertTrue( + "Triple-slash URL should match after normalization", + clientManager.shouldOverrideUrlLoading(mockWebView, mockRequest) + ) + verify { + mockViewModel.onWebServerFlowComplete( + code = "test_code", + onAuthFlowSuccess = mockResultCallback.onMigrationSuccess, + onAuthFlowError = mockResultCallback.onMigrationError, + loginServer = "instanceServer", + tokenMigration = true, + ) + } + + coVerify(exactly = 0) { + mockViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } + } + } + + // endregion + + // region onPageStarted / onPageFinished Tests + + @Test + fun onPageStarted_setsLoadingTrue() { + val loadingState: MutableState = mockk(relaxed = true) + val authFinishedState: MutableState = mockk(relaxed = true) + every { mockViewModel.loading } returns loadingState + every { mockViewModel.authFinished } returns authFinishedState + + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com") + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + clientManager.onPageStarted(mockWebView, "https://test.salesforce.com/page", null) + } + + verify { loadingState.value = true } + verify(exactly = 0) { authFinishedState.value = true } + } + + @Test + fun onPageFinished_setsBackgroundColor() { + val purpleColor = "rgb(128, 0, 128)" + val loadingState: MutableState = mockk(relaxed = true) + val backgroundColor: MutableState = mockk(relaxed = true) + every { mockViewModel.loading } returns loadingState + every { mockViewModel.authFinished } returns mutableStateOf(false) + every { mockViewModel.dynamicBackgroundColor } returns backgroundColor + every { mockWebView.evaluateJavascript(any(), any()) } answers { + secondArg>().onReceiveValue(purpleColor) + } + + val activity = launchActivity() + val mockResultCallback = createMockResultCallback() + val clientManager = activity.TokenMigrationClientManager(mockResultCallback, "https://test.salesforce.com") + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + clientManager.onPageFinished(mockWebView, "https://test.salesforce.com/page") + } + + verify { backgroundColor.value = Color(128, 0, 128) } + verify { loadingState.value = false } + } + + // endregion + + // region buildAuthWebview Tests + + @Test + fun buildAuthWebview_enablesJavaScript() { + val activity = launchActivity() + val resultCallback = createMockResultCallback() + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + try { + val webView = activity.buildAuthWebview( + frontDoorUrl = "https://test.salesforce.com/frontdoor", + resultCallback = resultCallback, + instanceServer = "https://test.salesforce.com", + ) + assertTrue("JavaScript should be enabled", webView.settings.javaScriptEnabled) + } catch (e: Throwable) { + Assert.fail(e.stackTraceToString()) + } + } + } + + @Test + fun buildAuthWebview_setsUserAgent() { + val activity = launchActivity() + val resultCallback = createMockResultCallback() + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + try { + val webView = activity.buildAuthWebview( + frontDoorUrl = "https://test.salesforce.com/frontdoor", + resultCallback = resultCallback, + instanceServer = "https://test.salesforce.com", + ) + assertTrue( + "User agent should contain SDK user agent", + webView.settings.userAgentString?.contains( + SalesforceSDKManager.getInstance().userAgent + ) == true, + ) + } catch (e: Throwable) { + Assert.fail(e.stackTraceToString()) + } + } + } + + @Test + fun buildAuthWebview_loadsProvidedUrl() { + val activity = launchActivity() + val resultCallback = createMockResultCallback() + var loadedUrl: String? = null + + TokenMigrationActivity.webViewFactory = { context -> + object : TestWebView(context) { + override fun loadUrl(url: String) { + loadedUrl = url + } + } + } + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + try { + activity.buildAuthWebview( + frontDoorUrl = "https://test.salesforce.com/frontdoor?sid=abc", + resultCallback = resultCallback, + instanceServer = "https://test.salesforce.com", + ) + } catch (e: Throwable) { + Assert.fail(e.stackTraceToString()) + } + } + + assertEquals( + "buildAuthWebview should load the provided frontDoorUrl", + "https://test.salesforce.com/frontdoor?sid=abc", + loadedUrl, + ) + } + + @Test + fun buildAuthWebview_assignsTokenMigrationClientManager() { + val activity = launchActivity() + val resultCallback = createMockResultCallback() + + TokenMigrationActivity.webViewFactory = { context -> + TestWebView(context).also { it.onLoadUrlCallback = { } } + } + + InstrumentationRegistry.getInstrumentation().runOnMainSync { + try { + val webView = activity.buildAuthWebview( + frontDoorUrl = "https://test.salesforce.com/frontdoor", + resultCallback = resultCallback, + instanceServer = "https://test.salesforce.com", + ) + assertTrue( + "WebViewClient should be TokenMigrationClientManager", + webView.webViewClient is TokenMigrationActivity.TokenMigrationClientManager, + ) + } catch (e: Throwable) { + Assert.fail(e.stackTraceToString()) + } + } + } + + // endregion + + // region Helpers + + /** + * Launches [TokenMigrationActivity] with intent extras. [UserAccountManager] + * returns null for any user lookup, so the activity finishes in [onCreate] + * before reaching [clientManager.peekRestClient] or the lifecycleScope + * coroutine. This avoids Compose rendering and coroutine-related hangs entirely. + * + * Uses [Application.ActivityLifecycleCallbacks] to capture the activity + * instance because [ActivityScenario.onActivity] cannot be used on + * activities that have already reached the DESTROYED state. + */ + private fun launchActivity(): TokenMigrationActivity { + val callbackKey = MigrationCallbackRegistry.register( + MigrationCallbackRegistry.MigrationCallbacks( + onMigrationSuccess = { }, + onMigrationError = { _, _, _ -> }, + ) + ) + val intent = Intent(getApplicationContext(), TokenMigrationActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey) + putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, mockOAuthConfig) + putExtra(TokenMigrationActivity.EXTRA_ORG_ID, VALID_ORG) + putExtra(TokenMigrationActivity.EXTRA_USER_ID, VALID_USER) + } + + var capturedActivity: TokenMigrationActivity? = null + val latch = CountDownLatch(1) + val app = getApplicationContext() + + val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is TokenMigrationActivity) { + capturedActivity = activity + latch.countDown() + } + } + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + + app.registerActivityLifecycleCallbacks(lifecycleCallbacks) + getApplicationContext().startActivity(intent) + latch.await(10, TimeUnit.SECONDS) + app.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + + testActivity = capturedActivity + ?: throw RuntimeException("TokenMigrationActivity was not created within timeout") + return testActivity!! + } + + private fun createMockResultCallback(): MigrationCallbackRegistry.MigrationCallbacks = + mockk { + every { onMigrationSuccess(any()) } just runs + every { onMigrationError(any(), any(), any()) } just runs + } + + private fun createMockWebResourceRequest(url: String): WebResourceRequest = + mockk { + every { this@mockk.url } returns url.toUri() + } + + // endregion +} \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml index 98dd7345e1..8b310df37c 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ - diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt index fb83a17e90..19accbcdca 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt @@ -36,6 +36,7 @@ import android.os.Build import android.os.Bundle import android.widget.ScrollView import android.widget.TextView +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi @@ -84,6 +85,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember @@ -94,6 +96,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource @@ -105,8 +108,10 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.accounts.migrateRefreshToken import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.JwtAccessToken +import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.rest.ApiVersionStrings import com.salesforce.androidsdk.rest.ClientManager import com.salesforce.androidsdk.rest.RestClient @@ -226,11 +231,14 @@ class AuthFlowTesterActivity : SalesforceActivity() { RevokeButtonCard() RequestButtonCard() - UserCredentialsView(currentUser.value) + // Use key() to force recomposition when tokens change (UserAccount.equals only compares userId/orgId) + key(currentUser.value?.authToken, currentUser.value?.refreshToken) { + UserCredentialsView(currentUser.value) - if (jwtTokenInUse) { - currentUser.value?.authToken?.let { token -> - JwtTokenView(jwtToken = JwtAccessToken(token)) + if (jwtTokenInUse) { + currentUser.value?.authToken?.let { token -> + JwtTokenView(jwtToken = JwtAccessToken(token)) + } } } @@ -287,7 +295,6 @@ class AuthFlowTesterActivity : SalesforceActivity() { ) { Button( onClick = { - @Suppress("AssignedValueIsNeverRead") coroutineScope.launch { revokeInProgress = true response = revokeAccessTokenAction(client) @@ -361,7 +368,6 @@ class AuthFlowTesterActivity : SalesforceActivity() { Card(modifier = Modifier.padding((INNER_CARD_PADDING/2).dp)) { Button( onClick = { - @Suppress("AssignedValueIsNeverRead") coroutineScope.launch { response = null requestInProgress = true @@ -457,6 +463,10 @@ class AuthFlowTesterActivity : SalesforceActivity() { var scopes by remember { mutableStateOf("") } val validInput = consumerKey.isNotBlank() && callbackUrl.isNotBlank() var showJsonImportDialog by remember { mutableStateOf(false) } + var migrationInProgress by remember { mutableStateOf(false) } + var migrationError: String? by remember { mutableStateOf(null) } + val clipboard = LocalClipboard.current + val context = LocalContext.current ModalBottomSheet( onDismissRequest = onDismiss, @@ -530,21 +540,66 @@ class AuthFlowTesterActivity : SalesforceActivity() { Button( modifier = Modifier.fillMaxWidth().padding(vertical = (INNER_CARD_PADDING/2).dp), shape = RoundedCornerShape(CORNER_SHAPE.dp), - enabled = validInput, - onClick = { }, + enabled = validInput && !migrationInProgress, + onClick = { + migrationInProgress = true + + UserAccountManager.getInstance().migrateRefreshToken( + appConfig = OAuthConfig( + consumerKey = consumerKey, + redirectUri = callbackUrl, + ), + onMigrationSuccess = { + runOnUiThread { + Toast.makeText( + this@AuthFlowTesterActivity, + resources.getString(R.string.migration_success), + Toast.LENGTH_LONG, + ).show() + } + onDismiss.invoke() + }, + onMigrationError = { error, errorDesc, e -> + migrationInProgress = false + migrationError = error + + (errorDesc?.let { " \n\nDesc: $it" } ?: "") + + (e?.let { "\n\nThrowable: $it" } ?: "") + }, + ) + }, ) { - Text( - text = stringResource(R.string.migrate_button), - fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium, - color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer, - ) + if (migrationInProgress) { + CircularProgressIndicator( + modifier = Modifier.size(INNER_CARD_PADDING.dp), + strokeWidth = SPINNER_STROKE_WIDTH.dp, + ) + Spacer(Modifier.width(INNER_CARD_PADDING.dp)) + Text( + text = stringResource(R.string.migration_in_progress), + fontWeight = FontWeight.Medium, + color = colorScheme.onErrorContainer, + ) + } else { + Text( + text = stringResource(R.string.migrate_button), + fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium, + color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer, + ) + } } } } if (showJsonImportDialog) { - var jsonInput by remember { mutableStateOf("") } - val alertBody = LocalContext.current.getString( + var jsonInput by remember { mutableStateOf( + // Default to clipboard text, if available. + value = clipboard.nativeClipboard.primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(/* index = */ 0) + ?.coerceToText(context) + ?.toString() ?: "" + ) } + val alertBody = context.getString( /* resId = */ R.string.json_import, /* ...formatArgs = */ CONSUMER_JSON_KEY, REDIRECT_JSON_KEY, SCOPE_JSON_KEY, ) @@ -564,7 +619,7 @@ class AuthFlowTesterActivity : SalesforceActivity() { modifier = Modifier.fillMaxWidth(), ) } - }, + }, confirmButton = { TextButton( onClick = { @@ -594,6 +649,21 @@ class AuthFlowTesterActivity : SalesforceActivity() { shape = RoundedCornerShape(CORNER_SHAPE.dp), ) } + + migrationError?.let { errorMessage -> + @Suppress("AssignedValueIsNeverRead") + AlertDialog( + onDismissRequest = { migrationError = null }, + title = { Text(stringResource(R.string.migration_failure)) }, + text = { Text(text = errorMessage) }, + confirmButton = { + TextButton(onClick = { migrationError = null }) { + Text("Ok") + } + }, + shape = RoundedCornerShape(CORNER_SHAPE.dp), + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt index f8b39b1168..d9a7d38c48 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt @@ -52,6 +52,9 @@ class AuthFlowTesterApplication : Application() { with(SalesforceSDKManager.getInstance()) { registerUsedAppFeature(FEATURE_APP_USES_KOTLIN) + // TODO: remove when W-20524841 is fixed + useHybridAuthentication = false + appConfigForLoginHost = { server: String -> var oauthConfig: OAuthConfig? = null val jsonConfig = ResourceReaderHelper.readAssetFile( diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt index 2e72e8616f..49e70942da 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt @@ -1,9 +1,38 @@ +/* + * 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.samples.authflowtester import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.rest.RestClient import com.salesforce.androidsdk.rest.RestRequest import com.salesforce.androidsdk.rest.RestResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -11,7 +40,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import java.net.URLEncoder import java.nio.charset.StandardCharsets import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine const val FAILED_OPERATION = "The operation could not be completed." const val UNKNOWN_ERROR = "An unexpected error has occurred." @@ -27,8 +55,10 @@ suspend fun revokeAccessTokenAction(client: RestClient?): RequestResult { // This should never happen. if (client == null) return RequestResult(success = false, AUTH_REQUIRED) - val token = SalesforceSDKManager.getInstance().userAccountManager.currentUser.authToken - val encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8.toString()) + val token = SalesforceSDKManager.getInstance().userAccountManager.currentUser?.authToken + val encodedToken = withContext(Dispatchers.IO) { + URLEncoder.encode(token, StandardCharsets.UTF_8.toString()) + } val body = "token=$encodedToken".toRequestBody( contentType = "application/x-www-form-urlencoded".toMediaType(), ) @@ -84,7 +114,7 @@ suspend fun makeRestRequest(client: RestClient?, apiVersion: String): RequestRes } suspend fun RestClient.sendAsync(request: RestRequest): Result { - return suspendCoroutine { continuation -> + return suspendCancellableCoroutine { continuation -> sendAsync(request, object : RestClient.AsyncRequestCallback { override fun onSuccess(request: RestRequest?, response: RestResponse?) { val result: Result = if (response == null) { diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt index 49f516f43c..099a9a2e1d 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt @@ -127,7 +127,7 @@ fun UserCredentialsView(currentUser: UserAccount?) { InfoSection(title = TOKENS) { InfoRowView(label = ACCESS_TOKEN, value = currentUser?.authToken, isSensitive = true) InfoRowView(label = REFRESH_TOKEN, value = currentUser?.refreshToken, isSensitive = true) - InfoRowView(label = TOKEN_FORMAT, value = currentUser?.tokenFormat) + InfoRowView(label = TOKEN_FORMAT, value = currentUser?.tokenFormat?.ifBlank { "Opaque" }) InfoRowView(label = SCOPES, value = formatScopes(currentUser)) } diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml new file mode 100644 index 0000000000..85a3f7ccc3 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-anydpi-v26/msdk_icon.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp new file mode 100644 index 0000000000..0224c30f9e Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-hdpi/msdk_icon_foreground.webp differ diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp new file mode 100644 index 0000000000..e4b3ff4820 Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-mdpi/msdk_icon_foreground.webp differ diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp new file mode 100644 index 0000000000..8ec89f5de9 Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xhdpi/msdk_icon_foreground.webp differ diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp new file mode 100644 index 0000000000..35f9f6004a Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxhdpi/msdk_icon_foreground.webp differ diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp new file mode 100644 index 0000000000..b267a739d4 Binary files /dev/null and b/native/NativeSampleApps/AuthFlowTester/src/main/res/mipmap-xxxhdpi/msdk_icon_foreground.webp differ diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml new file mode 100644 index 0000000000..d8b3750382 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/msdk_icon_background.xml @@ -0,0 +1,4 @@ + + + #414450 + \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml index 0e97750c6d..a16ee252e7 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml @@ -25,10 +25,12 @@ close import json Migrate Refresh Token + Migrating… Import Configuration Import Paste json with %1$s, %2$s and %3$s: - + Migration Success + Migration Failure (empty)