diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java index ac3bce9b0c..06865a205a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java @@ -39,6 +39,7 @@ import com.salesforce.androidsdk.app.Features; import com.salesforce.androidsdk.app.SalesforceSDKManager; +import com.salesforce.androidsdk.auth.ScopeParser; import com.salesforce.androidsdk.util.MapUtil; import com.salesforce.androidsdk.util.SalesforceSDKLogger; @@ -681,22 +682,6 @@ public String getScope() { return scope; } - /** - * Parses the space-delimited scope string into its individual components. - * - * @return Array of scope strings (empty if scope is null/empty). - */ - public String[] parseScopes() { - if (TextUtils.isEmpty(scope)) { - return new String[0]; - } - final String trimmed = scope.trim(); - if (trimmed.isEmpty()) { - return new String[0]; - } - return trimmed.split("\\s+"); - } - /** * Checks whether the provided scope exists in this account's scope list. * @@ -704,15 +689,7 @@ public String[] parseScopes() { * @return True if present, false otherwise. */ public boolean hasScope(String scopeToCheck) { - if (TextUtils.isEmpty(scopeToCheck)) { - return false; - } - for (final String s : parseScopes()) { - if (scopeToCheck.equals(s)) { - return true; - } - } - return false; + return new ScopeParser(scope).hasScope(scopeToCheck); } /** * Returns the beacon child consumer key. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index 6e03a7c650..24f28bb111 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -26,8 +26,10 @@ */ package com.salesforce.androidsdk.auth +import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import androidx.annotation.VisibleForTesting import com.salesforce.androidsdk.R.string.sf__generic_authentication_error import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title import com.salesforce.androidsdk.R.string.sf__managed_app_error @@ -46,6 +48,8 @@ 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 import com.salesforce.androidsdk.push.PushMessaging.register import com.salesforce.androidsdk.rest.RestClient.clearCaches @@ -85,6 +89,7 @@ private const val TAG = "AuthenticationUtilities" * * Creates an Account. * * Checks for any CA/ECA settings such as Screen Lock or Biometric Authentication. */ +@VisibleForTesting internal suspend fun onAuthFlowComplete( tokenResponse: TokenEndpointResponse, loginServer: String, @@ -93,11 +98,23 @@ internal suspend fun onAuthFlowComplete( onAuthFlowSuccess: (userAccount: UserAccount) -> Unit, buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName, nativeLogin: Boolean = false, -) { - val context = SalesforceSDKManager.getInstance().appContext - val userAccountManager = SalesforceSDKManager.getInstance().userAccountManager - val blockIntegrationUser = SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser && - fetchIsSalesforceIntegrationUser(tokenResponse, loginServer) + context: Context = SalesforceSDKManager.getInstance().appContext, + userAccountManager: UserAccountManager = SalesforceSDKManager.getInstance().userAccountManager, + blockIntegrationUser: Boolean = (SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser && + fetchIsSalesforceIntegrationUser(tokenResponse, loginServer)), + runtimeConfig: RuntimeConfig = getRuntimeConfig(SalesforceSDKManager.getInstance().appContext), + updateLoggingPrefs: (account: UserAccount) -> Unit = ::updateLoggingPrefsHelper, + fetchUserIdentity: (suspend (tokenResponse: TokenEndpointResponse) -> OAuth2.IdServiceResponse?)? = null, + startMainActivity: () -> Unit = ::startMainActivityHelper, + setAdministratorPreferences: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::setAdministratorPreferences, + 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, + ) { + + // Note: Can't use default parameter value for suspended function parameter fetchUserIdentity + val actualFetchUserIdentity = fetchUserIdentity ?: ::fetchUserIdentity if (blockIntegrationUser) { /* @@ -115,20 +132,24 @@ internal suspend fun onAuthFlowComplete( return } - val userIdentity = runCatching { - withContext(Default) { - callIdentityService( - HttpAccess.DEFAULT, - tokenResponse.idUrlWithInstance, - tokenResponse.authToken, - ) - } - }.onFailure { throwable -> - w(TAG, "Cannot fetch user identity due to an error.", throwable) - }.getOrNull() + // Create a ScopeParser from tokenResponse.scope + val scopeParser = ScopeParser(tokenResponse.scope) + + // Check that it has the refresh token scope, only warns if it is missing + if (!scopeParser.hasRefreshTokenScope()) { + w(TAG, "Missing refresh token scope.") + } + + // Check that the tokenResponse.scope contains the identity scope before calling the identity service + val userIdentity = if (scopeParser.hasIdentityScope()) { + actualFetchUserIdentity(tokenResponse) + } else { + w(TAG, "Missing identity scope, skipping identity service call.") + null + } val mustBeManagedApp = userIdentity?.customPermissions?.optBoolean(MUST_BE_MANAGED_APP_PERM) ?: false - if (mustBeManagedApp && !getRuntimeConfig(context).isManagedApp) { + if (mustBeManagedApp && !runtimeConfig.isManagedApp) { onAuthFlowError( context.getString(sf__generic_authentication_error_title), context.getString(sf__managed_app_error), null @@ -144,60 +165,12 @@ internal suspend fun onAuthFlowComplete( .clientId(consumerKey) .nativeLogin(nativeLogin) .build() - account.downloadProfilePhoto() // Set additional administrator prefs if they exist - userIdentity?.customAttributes?.let { customAttributes -> - SalesforceSDKManager.getInstance().adminSettingsManager?.setPrefs(customAttributes, account) - } + setAdministratorPreferences(userIdentity, account) - userIdentity?.customPermissions?.let { customPermissions -> - SalesforceSDKManager.getInstance().adminPermsManager?.setPrefs(customPermissions, account) - } - - SalesforceSDKManager.getInstance().userAccountManager.authenticatedUsers?.let { existingUsers -> - // Check if the user already exists - if (existingUsers.contains(account)) { - val duplicateUserAccount = existingUsers.removeAt(existingUsers.indexOf(account)) - clearCaches() - userAccountManager.clearCachedCurrentUser() - - // Revoke existing refresh token - if (account.refreshToken != duplicateUserAccount.refreshToken) { - runCatching { - URI(duplicateUserAccount.instanceServer) - }.onFailure { throwable -> - w(TAG, "Revoking token failed", throwable) - }.onSuccess { uri -> - // The user authenticated via webview again, unlock the app. - if (isBiometricAuthenticationEnabled(duplicateUserAccount)) { - (SalesforceSDKManager.getInstance().biometricAuthenticationManager - as? BiometricAuthenticationManager)?.onUnlock() - } - CoroutineScope(IO).launch { - revokeRefreshToken( - HttpAccess.DEFAULT, - uri, - duplicateUserAccount.refreshToken, - OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED, - ) - } - } - } - } - - // If this account has biometric authentication enabled remove any others that also have it - if (userIdentity?.biometricAuth == true) { - existingUsers.forEach(Consumer { existingUser -> - if (isBiometricAuthenticationEnabled(existingUser)) { - // This is an unexpected logout(s) because we only support one Bio Auth user. - userAccountManager.signoutUser( - existingUser, null, false, OAuth2.LogoutReason.UNEXPECTED - ) - } - }) - } - } + // Handle duplicate user account scenarios + handleDuplicateUserAccount(userAccountManager, account, userIdentity) // Save the user account addAccount(account) @@ -205,7 +178,7 @@ internal suspend fun onAuthFlowComplete( userAccountManager.switchToUser(account) // Init user logging - SalesforceAnalyticsManager.getInstance(account)?.updateLoggingPrefs() + updateLoggingPrefs(account) // Send User Switch Intent, create user and switch to user. val numAuthenticatedUsers = userAccountManager.authenticatedUsers?.size ?: 0 @@ -223,37 +196,16 @@ internal suspend fun onAuthFlowComplete( // Kickoff the end of the flow before storing mobile policy to prevent launching // the main activity over/after the screen lock. - with(SalesforceSDKManager.getInstance()) { - appContext.startActivity(Intent(appContext, mainActivityClass).apply { - setPackage(appContext.packageName) - flags = FLAG_ACTIVITY_NEW_TASK - }) - } + startMainActivity() // Let the calling process resume onAuthFlowSuccess(account) // Screen lock required by mobile policy - if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) { - SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK) - val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60 - (SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy( - account, - userIdentity.screenLock, - timeoutInMills - ) - } + handleScreenLockPolicy(userIdentity, account) // Biometric authorization required by mobile policy - if (userIdentity?.biometricAuth == true) { - SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) - val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000 - (SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy( - account, - userIdentity.biometricAuth, - timeoutInMills - ) - } + handleBiometricAuthPolicy(userIdentity, account) } internal fun defaultBuildAccountName( @@ -315,18 +267,22 @@ private fun HttpUrl.isSalesforceUrl(): Boolean { return salesforceHosts.map { host.endsWith(it) }.any { it } } -private fun addAccount(account: UserAccount?) { +private fun addAccount(account: UserAccount?, context: Context, isTestRun: Boolean, loginServerManager: LoginServerManager) { + + // Download profile photo + account?.downloadProfilePhoto() + /* * Registers for push notifications if setup by the app. This step needs * to happen after the account has been added by client manager, so that * the push service has all the account info it needs. */ - register(SalesforceSDKManager.getInstance().appContext, account) + register(context, account) when { - SalesforceSDKManager.getInstance().isTestRun -> logAddAccount(account) + isTestRun -> logAddAccount(account, loginServerManager) else -> CoroutineScope(IO).launch { - logAddAccount(account) + logAddAccount(account, loginServerManager) } } } @@ -336,12 +292,12 @@ private fun addAccount(account: UserAccount?) { * * @param account The user account */ -private fun logAddAccount(account: UserAccount?) { +private fun logAddAccount(account: UserAccount?, loginServerManager: LoginServerManager) { val attributes = JSONObject() runCatching { val users = UserAccountManager.getInstance().authenticatedUsers attributes.put("numUsers", users?.size ?: 0) - val servers = SalesforceSDKManager.getInstance().loginServerManager.loginServers + val servers = loginServerManager.loginServers attributes.put("numLoginServers", servers?.size ?: 0) servers?.let { serversUnwrapped -> val serversJson = JSONArray() @@ -357,3 +313,172 @@ private fun logAddAccount(account: UserAccount?) { e(TAG, "Exception thrown while creating JSON", throwable) } } + +/** + * Helper method to fetch user identity from token response. + */ +private suspend fun fetchUserIdentity( + tokenResponse: TokenEndpointResponse +): OAuth2.IdServiceResponse? { + return runCatching { + withContext(Default) { + callIdentityService( + HttpAccess.DEFAULT, + tokenResponse.idUrlWithInstance, + tokenResponse.authToken, + ) + } + }.onFailure { throwable -> + w(TAG, "Cannot fetch user identity due to an error.", throwable) + }.getOrNull() +} + +/** + * Helper method to set administrator preferences for a user account. + */ +private fun setAdministratorPreferences( + userIdentity: OAuth2.IdServiceResponse?, + account: UserAccount +) { + // Set additional administrator prefs if they exist + userIdentity?.customAttributes?.let { customAttributes -> + SalesforceSDKManager.getInstance().adminSettingsManager?.setPrefs(customAttributes, account) + } + + userIdentity?.customPermissions?.let { customPermissions -> + SalesforceSDKManager.getInstance().adminPermsManager?.setPrefs(customPermissions, account) + } +} + +/** + * Helper method to start main activity. + */ +private fun startMainActivityHelper() { + with(SalesforceSDKManager.getInstance()) { + appContext.startActivity( + Intent( + appContext, + mainActivityClass + ).apply { + setPackage(appContext.packageName) + flags = FLAG_ACTIVITY_NEW_TASK + }) + } +} + +/** + * Helper method to update logging preferences. + */ +private fun updateLoggingPrefsHelper(account: UserAccount) { + SalesforceAnalyticsManager.getInstance(account)?.updateLoggingPrefs() +} + +/** + * Helper method to handle screen lock mobile policy. + */ +private fun handleScreenLockPolicy( + userIdentity: OAuth2.IdServiceResponse?, + account: UserAccount +) { + if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) { + SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK) + val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60 + (SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy( + account, + userIdentity.screenLock, + timeoutInMills + ) + } +} + +/** + * Helper method to handle biometric authentication mobile policy. + */ +private fun handleBiometricAuthPolicy( + userIdentity: OAuth2.IdServiceResponse?, + account: UserAccount +) { + if (userIdentity?.biometricAuth == true) { + SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH) + val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000 + (SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy( + account, + userIdentity.biometricAuth, + timeoutInMills + ) + } +} + +/** + * Helper method to add account and perform related operations. + */ +private fun addAccountHelper( + account: UserAccount +) { + addAccount( + account, + SalesforceSDKManager.getInstance().appContext, + SalesforceSDKManager.getInstance().isTestRun, + SalesforceSDKManager.getInstance().loginServerManager + ) +} + +/** + * Helper method to handle duplicate user account scenarios during authentication. + * This method manages existing users by: + * - Removing duplicate accounts and clearing caches + * - Revoking old refresh tokens when a new one is provided + * - Unlocking biometric authentication for the duplicate user + * - Signing out other users with biometric auth when a new biometric user is added + */ +private fun handleDuplicateUserAccount( + userAccountManager: UserAccountManager, + account: UserAccount, + userIdentity: OAuth2.IdServiceResponse? +) { + userAccountManager.authenticatedUsers?.let { existingUsers -> + // Check if the user already exists + if (existingUsers.contains(account)) { + val duplicateUserAccount = existingUsers.removeAt(existingUsers.indexOf(account)) + clearCaches() + userAccountManager.clearCachedCurrentUser() + + // Revoke existing refresh token + if (account.refreshToken != duplicateUserAccount.refreshToken) { + runCatching { + URI(duplicateUserAccount.instanceServer) + }.onFailure { throwable -> + w(TAG, "Revoking token failed", throwable) + }.onSuccess { uri -> + // The user authenticated via webview again, unlock the app. + if (isBiometricAuthenticationEnabled(duplicateUserAccount)) { + (SalesforceSDKManager.getInstance().biometricAuthenticationManager + as? BiometricAuthenticationManager)?.onUnlock() + } + CoroutineScope(IO).launch { + CoroutineScope(IO).launch { + revokeRefreshToken( + HttpAccess.DEFAULT, + uri, + duplicateUserAccount.refreshToken, + OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED, + ) + } + } + } + } + } + + // If this account has biometric authentication enabled remove any others that also have it + if (userIdentity?.biometricAuth == true) { + existingUsers.forEach(Consumer { existingUser -> + if (isBiometricAuthenticationEnabled(existingUser)) { + // This is an unexpected logout(s) because we only support one Bio Auth user. + userAccountManager.signoutUser( + existingUser, null, false, OAuth2.LogoutReason.UNEXPECTED + ) + } + }) + } + } +} diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index d7c207efce..aafa4cb30d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -387,17 +387,13 @@ public static URI getFrontdoorUrl(URI url, } /** - * Computes the scope parameter from an array of scopes. Also adds - * the 'refresh_token' scope if it hasn't already been added. + * Computes the scope parameter from an array of scopes. * * @param scopes Array of scopes. - * @return Scope parameter. + * @return Scope parameter string (possibly empty). */ public static String computeScopeParameter(String[] scopes) { - final List scopesList = Arrays.asList(scopes == null ? new String[]{} : scopes); - final Set scopesSet = new TreeSet<>(scopesList); - scopesSet.add(REFRESH_TOKEN); - return TextUtils.join(SINGLE_SPACE, scopesSet.toArray(new String[]{})); + return ScopeParser.computeScopeParameter(scopes); } /** diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt new file mode 100644 index 0000000000..7968d6b171 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.auth + +import android.text.TextUtils + +/** + * Utility class for parsing and working with OAuth2 scopes. + */ +class ScopeParser { + + companion object { + const val REFRESH_TOKEN = "refresh_token" + const val ID = "id" + + /** + * Factory method that creates a ScopeParser from a space-delimited scope string. + * + * @param scopeString Space-delimited scope string. + * @return ScopeParser instance. + */ + @JvmStatic + fun parseScopes(scopeString: String?): ScopeParser { + return ScopeParser(scopeString) + } + + /** + * Computes the scope parameter from an array of scopes. + * + * Behavior: + * - If {@code scopes} is null or empty, returns an empty string. This indicates that all + * scopes assigned to the connected app / external client app will be requested by default + * (no explicit scope parameter is sent). + * - If {@code scopes} is non-empty, ensures {@code refresh_token} is present in the set and + * returns a space-delimited string of unique, sorted scopes. + * + * @param scopes Array of scopes. + * @return Scope parameter string (possibly empty). + */ + @JvmStatic + fun computeScopeParameter(scopes: Array?): String { + // If no scopes are provided, return an empty string. This indicates that all scopes + // assigned to the connected app / external client app will be requested by default. + if (scopes.isNullOrEmpty()) { + return "" + } + + // When explicit scopes are provided, ensure REFRESH_TOKEN is included. + val scopesSet = scopes.toSortedSet() + scopesSet.add(REFRESH_TOKEN) + return scopesSet.joinToString(" ") + } + } + + private val _scopes: MutableSet + + /** + * Constructor that takes an array of scopes. + * + * @param scopes Array of scopes. + */ + constructor(scopes: Array?) { + this._scopes = sortedSetOf() + scopes?.forEach { scope -> + if (!TextUtils.isEmpty(scope)) { + this._scopes.add(scope.trim()) + } + } + } + + /** + * Constructor that takes a space-delimited scope string. + * + * @param scopeString Space-delimited scope string. + */ + constructor(scopeString: String?) { + this._scopes = scopeString + ?.trim() + ?.split("\\s+".toRegex()) + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toMutableSet() ?: mutableSetOf() + } + + + /** + * Checks whether the provided scope exists in this parser's scope set. + * + * @param scope Scope name to check. + * @return True if present, false otherwise. + */ + fun hasScope(scope: String?): Boolean { + return scope?.isNotBlank() == true && _scopes.contains(scope.trim()) + } + + /** + * Checks whether the refresh_token scope exists in this parser's scope set. + * + * @return True if refresh_token scope is present, false otherwise. + */ + fun hasRefreshTokenScope(): Boolean { + return hasScope(REFRESH_TOKEN) + } + + /** + * Checks whether the id scope exists in this parser's scope set. + * + * @return True if id scope is present, false otherwise. + */ + fun hasIdentityScope(): Boolean { + return hasScope(ID) + } + + /** + * Returns the set of scopes. + * + * @return Set of scopes. + */ + val scopes: Set + get() = _scopes.toSet() + + /** + * Returns the scopes as a space-delimited string. + * + * @return Space-delimited scope string. + */ + val scopesAsString: String + get() = if (_scopes.isEmpty()) { + "" + } else { + _scopes.sorted().joinToString(" ") + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java index 205bddded4..3a05d7933d 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java @@ -127,26 +127,6 @@ public void testConvertAccountToBundle() { BundleTestHelper.checkSameBundle("UserAccount bundles do not match", expected, actual); } - @Test - public void testParseScopes() { - UserAccount account = createTestAccount(); - String[] scopes = account.parseScopes(); - // Our TEST_SCOPE is "api web openid refresh_token" - Assert.assertArrayEquals(new String[]{"api", "web", "openid", "refresh_token"}, scopes); - - // Empty / null handling - UserAccount emptyScope = UserAccountBuilder.getInstance() - .populateFromUserAccount(account) - .scope(null) - .build(); - Assert.assertArrayEquals(new String[]{}, emptyScope.parseScopes()); - - UserAccount blankScope = UserAccountBuilder.getInstance() - .populateFromUserAccount(account) - .scope(" ") - .build(); - Assert.assertArrayEquals(new String[]{}, blankScope.parseScopes()); - } @Test public void testHasScope() { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt new file mode 100644 index 0000000000..55bb12ae4e --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.auth + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +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.config.RuntimeConfig +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for AuthenticationUtilities. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +@ExperimentalCoroutinesApi +class AuthenticationUtilitiesTest { + + private lateinit var testContext: Context + private val mockUserAccountManager: UserAccountManager = mockk() + private val mockRuntimeConfig: RuntimeConfig = mockk() + private val onAuthFlowError: (String, String?, Throwable?) -> Unit = mockk() + private val onAuthFlowSuccess: (UserAccount) -> Unit = mockk() + private val buildAccountName: (String?, String?) -> String = { username, instanceServer -> "$username ($instanceServer)" } + private val updateLoggingPrefs: (UserAccount) -> Unit = mockk() + private val fetchUserIdentity: suspend (OAuth2.TokenEndpointResponse) -> OAuth2.IdServiceResponse? = mockk() + private val startMainActivity: () -> Unit = mockk() + private val setAdministratorPreferences: (OAuth2.IdServiceResponse?, UserAccount) -> Unit = mockk() + private val addAccount: (UserAccount) -> Unit = mockk() + 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() { + // Setup test context + testContext = InstrumentationRegistry.getInstrumentation().targetContext + + // Setup mock runtime config + every { mockRuntimeConfig.isManagedApp } returns false + + // Setup mock user account manager + every { mockUserAccountManager.authenticatedUsers } returns mutableListOf() + + // Setup mock behaviors + every { onAuthFlowError.invoke(any(), any(), any()) } returns Unit + every { onAuthFlowSuccess.invoke(any()) } returns Unit + every { updateLoggingPrefs.invoke(any()) } returns Unit + every { startMainActivity.invoke() } returns Unit + every { setAdministratorPreferences.invoke(any(), any()) } returns Unit + every { addAccount.invoke(any()) } returns Unit + every { handleScreenLockPolicy.invoke(any(), any()) } returns Unit + every { handleBiometricAuthPolicy.invoke(any(), any()) } returns Unit + every { handleDuplicateUserAccount.invoke(any(), any(), any()) } returns Unit + + // Setup mock for UserAccountManager methods + every { mockUserAccountManager.createAccount(any()) } returns mockk() + every { mockUserAccountManager.switchToUser(any()) } returns Unit + every { mockUserAccountManager.sendUserSwitchIntent(any(), any()) } returns Unit + + } + + @Test + fun testOnAuthFlowComplete_blockIntegrationUser_shouldCallError() = runTest { + // Given + blockIntegrationUser = true + + // When + callOnAuthFlowComplete() + + // Then + verify { onAuthFlowError.invoke("Error", "Authentication error. Please try again.", null) } + verify(exactly = 0) { onAuthFlowSuccess.invoke(any()) } + verify(exactly = 0) { mockUserAccountManager.createAccount(any()) } + verify(exactly = 0) { mockUserAccountManager.switchToUser(any()) } + } + + @Test + fun testOnAuthFlowComplete_managedAppRequirement_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 + callOnAuthFlowComplete() + + // Then + verify { onAuthFlowError.invoke("Error", "Authentication only allowed from managed device.", null) } + verify(exactly = 0) { onAuthFlowSuccess.invoke(any()) } + verify(exactly = 0) { mockUserAccountManager.createAccount(any()) } + verify(exactly = 0) { mockUserAccountManager.switchToUser(any()) } + } + + @Test + fun testOnAuthFlowComplete_successfulFlow_shouldCallSuccess() = runTest { + // Given + val tokenResponse = createTokenEndpointResponse() + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + + // Create the expected UserAccount object like in AuthenticationUtilities.kt + val expectedAccount = UserAccountBuilder.getInstance() + .populateFromTokenEndpointResponse(tokenResponse) + .populateFromIdServiceResponse(userIdentity) + .accountName(buildAccountName(userIdentity.username, tokenResponse.instanceUrl)) + .loginServer("https://login.salesforce.com") + .clientId("test_consumer_key") + .nativeLogin(nativeLogin) + .build() + + // When + callOnAuthFlowComplete() + + // Then + verify(exactly = 0) { onAuthFlowError.invoke(any(), any(), any()) } + verify { onAuthFlowSuccess.invoke(expectedAccount) } + verify { mockUserAccountManager.createAccount(expectedAccount) } + verify { mockUserAccountManager.switchToUser(expectedAccount) } + verify { setAdministratorPreferences.invoke(userIdentity, expectedAccount) } + verify { handleDuplicateUserAccount.invoke(mockUserAccountManager, expectedAccount, userIdentity) } + verify { addAccount.invoke(expectedAccount) } + verify { updateLoggingPrefs.invoke(expectedAccount) } + verify { startMainActivity.invoke() } + verify { handleScreenLockPolicy.invoke(userIdentity, expectedAccount) } + verify { handleBiometricAuthPolicy.invoke(userIdentity, expectedAccount) } + } + + @Test + fun testOnAuthFlowComplete_successfulFlowWithoutIdScope_shouldCallSuccess() = runTest { + // Given + val tokenResponseWithoutIdScope = createTokenEndpointResponse( + scope = "refresh_token" // Missing id scope + ) + + // 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(nativeLogin) + .build() + + // When + callOnAuthFlowComplete(tokenResponseWithoutIdScope) + + // 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 never called since there's no id scope + coVerify(exactly = 0) { fetchUserIdentity.invoke(any()) } + } + + private suspend fun callOnAuthFlowComplete(customTokenResponse: OAuth2.TokenEndpointResponse? = null) { + onAuthFlowComplete( + tokenResponse = customTokenResponse ?: createTokenEndpointResponse(), + loginServer = "https://login.salesforce.com", + consumerKey = "test_consumer_key", + onAuthFlowError = onAuthFlowError, + onAuthFlowSuccess = onAuthFlowSuccess, + buildAccountName = buildAccountName, + nativeLogin = nativeLogin, + context = testContext, + userAccountManager = mockUserAccountManager, + blockIntegrationUser = blockIntegrationUser, + runtimeConfig = mockRuntimeConfig, + updateLoggingPrefs = updateLoggingPrefs, + fetchUserIdentity = fetchUserIdentity, + startMainActivity = startMainActivity, + setAdministratorPreferences = setAdministratorPreferences, + addAccount = addAccount, + handleScreenLockPolicy = handleScreenLockPolicy, + handleBiometricAuthPolicy = handleBiometricAuthPolicy, + handleDuplicateUserAccount = handleDuplicateUserAccount + ) + } + + private fun createTokenEndpointResponse( + accessToken: String = "test_access_token", + refreshToken: String? = "test_refresh_token", + instanceUrl: String = "https://test.salesforce.com", + idUrl: String = "https://test.salesforce.com/id/00D000000000000EAA/005000000000000AAA", + scope: String = "refresh_token id" + ): OAuth2.TokenEndpointResponse { + val params = mutableMapOf( + "access_token" to accessToken, + "instance_url" to instanceUrl, + "id" to idUrl, + "scope" to scope + ) + refreshToken?.let { params["refresh_token"] = it } + return OAuth2.TokenEndpointResponse(params) + } + + private fun createIdServiceResponse( + username: String = "test@example.com", + email: String = "test@example.com", + firstName: String = "Test", + lastName: String = "User", + displayName: String = "Test User", + nickname: String = "testuser", + userType: String = "STANDARD", + language: String = "en_US", + locale: String = "en_US", + lastModifiedDate: String = "2023-01-01T00:00:00Z", + userId: String = "005000000000000AAA", + organizationId: String = "00D000000000000EAA", + idUrl: String = "https://test.salesforce.com/id/00D000000000000EAA/005000000000000AAA", + active: Boolean = true, + utcOffset: Int = -28800000, + pictureUrl: String = "https://test.salesforce.com/profilephoto/005/F", + thumbnailUrl: String = "https://test.salesforce.com/profilephoto/005/T", + customPermissions: JSONObject? = null + ): OAuth2.IdServiceResponse { + return OAuth2.IdServiceResponse(JSONObject().apply { + put("id", idUrl) + put("username", username) + put("email", email) + put("first_name", firstName) + put("last_name", lastName) + put("display_name", displayName) + put("nick_name", nickname) + put("user_type", userType) + put("language", language) + put("locale", locale) + put("last_modified_date", lastModifiedDate) + put("user_id", userId) + put("organization_id", organizationId) + put("active", active) + put("utcOffset", utcOffset) + put("photos", JSONObject().apply { + put("picture", pictureUrl) + put("thumbnail", thumbnailUrl) + }) + put("urls", JSONObject().apply { + put("enterprise", "https://test.salesforce.com/services/Soap/c/{version}/00D000000000000EAA") + put("metadata", "https://test.salesforce.com/services/Soap/m/{version}/00D000000000000EAA") + put("partner", "https://test.salesforce.com/services/Soap/u/{version}/00D000000000000EAA") + put("rest", "https://test.salesforce.com/services/data/v{version}/") + put("sobjects", "https://test.salesforce.com/services/data/v{version}/sobjects/") + put("search", "https://test.salesforce.com/services/data/v{version}/search/") + put("query", "https://test.salesforce.com/services/data/v{version}/query/") + put("recent", "https://test.salesforce.com/services/data/v{version}/recent/") + put("profile", "https://test.salesforce.com/005000000000000AAA") + put("feeds", "https://test.salesforce.com/services/data/v{version}/chatter/feeds") + put("groups", "https://test.salesforce.com/services/data/v{version}/chatter/groups") + put("users", "https://test.salesforce.com/services/data/v{version}/chatter/users") + put("feed_items", "https://test.salesforce.com/services/data/v{version}/chatter/feed-items") + }) + customPermissions?.let { put("custom_permissions", it) } + }) + } +} \ No newline at end of file diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java index 9c331d10c7..a04ffac03f 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java @@ -61,7 +61,6 @@ import okhttp3.HttpUrl; import okhttp3.Request; import okhttp3.Response; -import okio.Buffer; /** * Tests for OAuth2. @@ -515,4 +514,43 @@ private String getRequestBodyAsString(Request request) throws IOException { } return buffer.readUtf8(); } + + /** + * Testing computeScopeParameter with null input. + */ + @Test + public void testComputeScopeParameterWithNull() { + String result = OAuth2.computeScopeParameter(null); + Assert.assertEquals("Should return empty string for null input", "", result); + } + + /** + * Testing computeScopeParameter with empty array. + */ + @Test + public void testComputeScopeParameterWithEmptyArray() { + String result = OAuth2.computeScopeParameter(new String[]{}); + Assert.assertEquals("Should return empty string for empty array", "", result); + } + + /** + * Testing computeScopeParameter when refresh_token is not included. + */ + @Test + public void testComputeScopeParameterWhenRefreshTokenNotIncluded() { + String result = OAuth2.computeScopeParameter(new String[]{"web", "api", "visualforce"}); + // TreeSet sorts alphabetically, so expected order is: api refresh_token visualforce web + Assert.assertEquals("Should include all scopes plus refresh_token, sorted alphabetically", "api refresh_token visualforce web", result); + } + + /** + * Testing computeScopeParameter when refresh_token is already included. + */ + @Test + public void testComputeScopeParameterWhenRefreshTokenIncluded() { + String result = OAuth2.computeScopeParameter(new String[]{"api", "refresh_token", "web"}); + // refresh_token should not be duplicated + Assert.assertEquals("Should not duplicate refresh_token", "api refresh_token web", result); + } + } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt new file mode 100644 index 0000000000..c4430a6dbb --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.auth + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for ScopeParser. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class ScopeParserTest { + + /** + * Testing ScopeParser constructor with array of scopes. + */ + @Test + fun testScopeParserConstructorWithArray() { + val parser = ScopeParser(arrayOf("api", "web", "refresh_token", "id")) + Assert.assertTrue("Should have api scope", parser.hasScope("api")) + Assert.assertTrue("Should have web scope", parser.hasScope("web")) + Assert.assertTrue("Should have refresh_token scope", parser.hasRefreshTokenScope()) + Assert.assertTrue("Should have id scope", parser.hasIdentityScope()) + Assert.assertFalse("Should not have unknown scope", parser.hasScope("unknown")) + } + + /** + * Testing ScopeParser constructor with empty array. + */ + @Test + fun testScopeParserConstructorWithEmptyArray() { + val parser = ScopeParser(arrayOf()) + Assert.assertFalse("Should not have any scopes", parser.hasScope("api")) + Assert.assertFalse("Should not have refresh_token scope", parser.hasRefreshTokenScope()) + Assert.assertEquals("Should have empty scope set", 0, parser.scopes.size) + } + + /** + * Testing ScopeParser constructor with scope string. + */ + @Test + fun testScopeParserConstructorWithString() { + val parser = ScopeParser("api web refresh_token id") + Assert.assertTrue("Should have api scope", parser.hasScope("api")) + Assert.assertTrue("Should have web scope", parser.hasScope("web")) + Assert.assertTrue("Should have refresh_token scope", parser.hasRefreshTokenScope()) + Assert.assertTrue("Should have id scope", parser.hasIdentityScope()) + Assert.assertFalse("Should not have unknown scope", parser.hasScope("unknown")) + } + + /** + * Testing ScopeParser constructor with null string. + */ + @Test + fun testScopeParserConstructorWithNullString() { + val parser = ScopeParser(null as String?) + Assert.assertFalse("Should not have any scopes", parser.hasScope("api")) + Assert.assertFalse("Should not have refresh_token scope", parser.hasRefreshTokenScope()) + Assert.assertEquals("Should have empty scope set", 0, parser.scopes.size) + } + + /** + * Testing ScopeParser constructor with empty string. + */ + @Test + fun testScopeParserConstructorWithEmptyString() { + val parser = ScopeParser("") + Assert.assertFalse("Should not have any scopes", parser.hasScope("api")) + Assert.assertFalse("Should not have refresh_token scope", parser.hasRefreshTokenScope()) + Assert.assertEquals("Should have empty scope set", 0, parser.scopes.size) + } + + /** + * Testing ScopeParser constructor with whitespace-only string. + */ + @Test + fun testScopeParserConstructorWithWhitespaceString() { + val parser = ScopeParser(" \t\n ") + Assert.assertFalse("Should not have any scopes", parser.hasScope("api")) + Assert.assertFalse("Should not have refresh_token scope", parser.hasRefreshTokenScope()) + Assert.assertEquals("Should have empty scope set", 0, parser.scopes.size) + } + + /** + * Testing ScopeParser parseScopes factory method. + */ + @Test + fun testScopeParserParseScopes() { + val parser = ScopeParser.parseScopes("api web id refresh_token") + Assert.assertTrue("Should have api scope", parser.hasScope("api")) + Assert.assertTrue("Should have web scope", parser.hasScope("web")) + Assert.assertTrue("Should have id scope", parser.hasIdentityScope()) + Assert.assertTrue("Should have refresh_token scope", parser.hasRefreshTokenScope()) + } + + /** + * Testing ScopeParser hasScope method. + */ + @Test + fun testScopeParserHasScope() { + val parser = ScopeParser("api web refresh_token") + + // Test existing scopes + Assert.assertTrue("Should have api scope", parser.hasScope("api")) + Assert.assertTrue("Should have web scope", parser.hasScope("web")) + Assert.assertTrue("Should have refresh_token scope", parser.hasScope("refresh_token")) + + // Test non-existing scope + Assert.assertFalse("Should not have unknown scope", parser.hasScope("unknown")) + + // Test null/empty scope + Assert.assertFalse("Should return false for null scope", parser.hasScope(null)) + Assert.assertFalse("Should return false for empty scope", parser.hasScope("")) + Assert.assertFalse("Should return false for whitespace scope", parser.hasScope(" ")) + + // Test trimming + Assert.assertTrue("Should handle leading/trailing whitespace", parser.hasScope(" api ")) + } + + /** + * Testing ScopeParser hasRefreshTokenScope method. + */ + @Test + fun testScopeParserHasRefreshTokenScope() { + val parserWithRefresh = ScopeParser("api web refresh_token") + Assert.assertTrue("Should have refresh_token scope", parserWithRefresh.hasRefreshTokenScope()) + + val parserWithoutRefresh = ScopeParser("api web") + Assert.assertFalse("Should not have refresh_token scope", parserWithoutRefresh.hasRefreshTokenScope()) + + val emptyParser = ScopeParser("") + Assert.assertFalse("Empty parser should not have refresh_token scope", emptyParser.hasRefreshTokenScope()) + } + + /** + * Testing ScopeParser hasIdScope method. + */ + @Test + fun testScopeParserHasIdentityScope() { + val parserWithId = ScopeParser("api web id") + Assert.assertTrue("Should have id scope", parserWithId.hasIdentityScope()) + + val parserWithoutId = ScopeParser("api web refresh_token") + Assert.assertFalse("Should not have id scope", parserWithoutId.hasIdentityScope()) + + val emptyParser = ScopeParser("") + Assert.assertFalse("Empty parser should not have id scope", emptyParser.hasIdentityScope()) + } + + /** + * Testing ScopeParser with duplicate scopes. + */ + @Test + fun testScopeParserDuplicateScopes() { + val parser = ScopeParser("api web api web refresh_token") + Assert.assertTrue("Should have api scope", parser.hasScope("api")) + Assert.assertTrue("Should have web scope", parser.hasScope("web")) + Assert.assertTrue("Should have refresh_token scope", parser.hasRefreshTokenScope()) + + // Should deduplicate - only 3 unique scopes + Assert.assertEquals("Should deduplicate scopes", 3, parser.scopes.size) + } + + /** + * Testing ScopeParser getScopesAsString method. + */ + @Test + fun testScopeParserGetScopesAsString() { + val parser = ScopeParser("web api refresh_token") + val scopesString = parser.scopesAsString + // Should be sorted alphabetically + Assert.assertEquals("Should return sorted scope string", "api refresh_token web", scopesString) + + val emptyParser = ScopeParser("") + Assert.assertEquals("Empty parser should return empty string", "", emptyParser.scopesAsString) + } + + /** + * Testing ScopeParser.computeScopeParameter method. + */ + @Test + fun testScopeParserComputeScopeParameter() { + // Test with null + Assert.assertEquals("Should return empty string for null", "", ScopeParser.computeScopeParameter(null)) + + // Test with empty array + Assert.assertEquals("Should return empty string for empty array", "", ScopeParser.computeScopeParameter(arrayOf())) + + // Test with single scope + Assert.assertEquals("Should add refresh_token to single scope", "api refresh_token", + ScopeParser.computeScopeParameter(arrayOf("api"))) + + // Test when refresh_token is not included + Assert.assertEquals("Should add refresh_token and sort", "api refresh_token visualforce web", + ScopeParser.computeScopeParameter(arrayOf("web", "api", "visualforce"))) + + // Test when refresh_token already included + Assert.assertEquals("Should not duplicate refresh_token", "api refresh_token web", + ScopeParser.computeScopeParameter(arrayOf("api", "refresh_token", "web"))) + + // Test with only refresh_token + Assert.assertEquals("Should return only refresh_token", "refresh_token", + ScopeParser.computeScopeParameter(arrayOf("refresh_token"))) + } + +}