diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java index 8bbac9a752..e6ff6bfb52 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java @@ -379,8 +379,20 @@ public AccMgrAuthTokenProvider(ClientManager clientManager, String instanceUrl, @Override public String getNewAuthToken() { SalesforceSDKLogger.i(TAG, "Need new access token"); - final Account acc = clientManager.getAccount(); - if (acc == null) { + UserAccountManager userAccountManager = SalesforceSDKManager.getInstance().getUserAccountManager(); + Account[] accounts = clientManager.getAccounts(); + Account matchingAccount = null; + + // Find the account for this client. + for (Account account : accounts) { + UserAccount user = userAccountManager.buildUserAccount(account); + if (user != null && lastNewAuthToken.equals(user.getAuthToken())) { + matchingAccount = account; + } + } + + // Fail early to ensure we don't logout the current user below by sending null. + if (matchingAccount == null) { return null; } @@ -401,9 +413,8 @@ public String getNewAuthToken() { try { // Invalidate current auth token. - final String cachedAuthToken = clientManager.peekRestClient(acc).getAuthToken(); - clientManager.invalidateToken(cachedAuthToken); - final UserAccount userAccount = refreshStaleToken(acc); + clientManager.invalidateToken(lastNewAuthToken); + final UserAccount userAccount = refreshStaleToken(matchingAccount); // NB: userAccount will be null if refresh token is no longer valid newAuthToken = userAccount != null ? userAccount.getAuthToken() : null; @@ -417,11 +428,12 @@ public String getNewAuthToken() { if (Looper.myLooper() == null) { Looper.prepare(); } + boolean showLoginPage = accounts.length > 1; // Note: As of writing (2024) this call will never succeed because revoke API is an // authenticated endpoint. However, there is no harm in attempting and the debug logs // produced may help developers better understand the state of their app. SalesforceSDKManager.getInstance() - .logout(null, null, false, OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED); + .logout(matchingAccount, null, showLoginPage, OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED); } // Broadcasts an intent that the refresh token has been revoked. diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt new file mode 100644 index 0000000000..14fce6937f --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt @@ -0,0 +1,428 @@ +package com.salesforce.androidsdk.rest + +import android.accounts.Account +import android.content.Context +import android.content.Intent +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.accounts.UserAccountManager +import com.salesforce.androidsdk.accounts.UserAccountManagerTest +import com.salesforce.androidsdk.analytics.EventBuilderHelper +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.HttpAccess +import com.salesforce.androidsdk.auth.OAuth2 +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.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +private const val OLD_TOKEN = "old-token" +private const val REFRESHED_TOKEN = "refreshed-auth-token" + +@SmallTest +class ClientManagerMockTest { + private lateinit var clientManager: ClientManager + private lateinit var mockSDKManager: SalesforceSDKManager + private lateinit var mockAppContext: Context + private lateinit var mockUserAccountManager: UserAccountManager + private lateinit var refreshResponse: Response + + @Before + fun setUp() { + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + clientManager = ClientManager(targetContext, UserAccountManagerTest.TEST_ACCOUNT_TYPE, true) + mockUserAccountManager = mockk(relaxed = true) + mockAppContext = mockk(relaxed = true) { + every { packageName } returns "packageName" + every { sendBroadcast(any()) } just runs + every { externalCacheDir } returns null + } + + mockkObject(SalesforceSDKManager) + mockSDKManager = mockk { + every { + logout(any(), any(), any(), any()) + } returns Unit + every { registerUsedAppFeature(any()) } returns true + every { unregisterUsedAppFeature(any()) } returns true + every { userAccountManager } returns mockUserAccountManager + every { deviceId } returns "test-device-id-123" + every { additionalOauthKeys } returns emptyList() + every { useHybridAuthentication } returns true + every { appContext } returns mockAppContext + } + every { SalesforceSDKManager.getInstance() } returns mockSDKManager + mockkStatic(UserAccountManager::class) + every { UserAccountManager.getInstance() } returns mockUserAccountManager + mockkStatic(EventBuilderHelper::class) + every { EventBuilderHelper.createAndStoreEvent(any(), any(), any(), any()) } just runs + + val responseBody = """ + { + "access_token": $REFRESHED_TOKEN, + "instance_url": "https://login.salesforce.com", + "id": "https://login.salesforce.com/id/orgId/userId", + "token_type": "Bearer", + "issued_at": "1234567890", + "signature": "mock-signature" + } + """.trimIndent().toResponseBody("application/json; charset=utf-8".toMediaType()) + refreshResponse = mockk(relaxed = true) { + every { isSuccessful } returns true + every { close() } just runs + every { body } returns responseBody + } + + mockkObject(HttpAccess.DEFAULT) + every { HttpAccess.DEFAULT.okHttpClient } returns mockk { + every { newCall(any()) } returns mockk { + every { execute() } returns refreshResponse + } + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun testGetNewAuthToken_MatchingAccount() { + val userSlot = slot() + val broadcastIntentSlot = slot() + val mockAccount = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns arrayOf(mockAccount) + } + every { mockUserAccountManager.currentUser } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + every { mockUserAccountManager.updateAccount(mockAccount, any()) } returns mockk() + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + mockClientManager, + "https://login.salesforce.com", + OLD_TOKEN, + "", + ) + + val result = authTokenProvider.getNewAuthToken() + Assert.assertEquals(REFRESHED_TOKEN, result) + + verify(exactly = 0) { + mockSDKManager.logout(any(), any(), any(), any()) + } + verify(exactly = 1) { + mockClientManager.invalidateToken(OLD_TOKEN) + mockUserAccountManager.updateAccount(mockAccount, capture(userSlot)) + mockAppContext.sendBroadcast(capture(broadcastIntentSlot)) + } + Assert.assertEquals(REFRESHED_TOKEN, userSlot.captured.authToken) + Assert.assertEquals(ClientManager.ACCESS_TOKEN_REFRESH_INTENT, broadcastIntentSlot.captured.action) + } + + @Test + fun testGetNewAuthToken_InstanceUrlChange() { + val userSlot = slot() + val broadcastIntentSlot = slot() + val mockAccount = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns arrayOf(mockAccount) + } + every { mockUserAccountManager.currentUser } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + every { mockUserAccountManager.updateAccount(mockAccount, any()) } returns mockk() + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + mockClientManager, + "https://not.login.salesforce.com", + OLD_TOKEN, + "", + ) + + val result = authTokenProvider.getNewAuthToken() + Assert.assertEquals(REFRESHED_TOKEN, result) + + verify(exactly = 0) { + mockSDKManager.logout(any(), any(), any(), any()) + } + verify(exactly = 1) { + mockClientManager.invalidateToken(OLD_TOKEN) + mockUserAccountManager.updateAccount(mockAccount, capture(userSlot)) + mockAppContext.sendBroadcast(capture(broadcastIntentSlot)) + } + Assert.assertEquals(REFRESHED_TOKEN, userSlot.captured.authToken) + Assert.assertEquals(ClientManager.INSTANCE_URL_UPDATE_INTENT, broadcastIntentSlot.captured.action) + } + + @Test + fun testGetNewAuthToken_NoAccounts() { + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns emptyArray() + } + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + mockClientManager, + "", + OLD_TOKEN, + "", + ) + + Assert.assertNull(authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockSDKManager.logout(any(), any(), any(), any()) + mockClientManager.invalidateToken(any()) + mockAppContext.sendBroadcast(any()) + } + } + + @Test + fun testGetNewAuthToken_NoMatchingAccount() { + val mockAccount = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns "not-matching" + } + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns emptyArray() + } + every { mockUserAccountManager.currentUser } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + mockClientManager, + "", + OLD_TOKEN, + "", + ) + + Assert.assertNull(authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockSDKManager.logout(any(), any(), any(), any()) + mockClientManager.invalidateToken(any()) + mockAppContext.sendBroadcast(any()) + } + } + + @Test + fun testGetNewAuthToken_Multiuser() { + val user2Token = "user2-token" + val userSlot = slot() + val mockAccount = mockk(relaxed = true) + val mockAccount2 = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + val mockUser2 = mockk(relaxed = true) { + every { authToken } returns user2Token + every { loginServer } returns "https://login.salesforce.com" + } + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns arrayOf(mockAccount, mockAccount2) + } + every { mockUserAccountManager.currentUser } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount2) } returns mockUser2 + every { mockUserAccountManager.updateAccount(mockAccount, any()) } returns mockk() + every { mockUserAccountManager.updateAccount(mockAccount2, any()) } returns mockk() + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + mockClientManager, + "https://login.salesforce.com", + OLD_TOKEN, + "", + ) + + Assert.assertEquals(REFRESHED_TOKEN, authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockClientManager.invalidateToken(user2Token) + mockSDKManager.logout(any(), any(), any(), any()) + mockUserAccountManager.updateAccount(mockAccount2, any()) + } + verify(exactly = 1) { + mockClientManager.invalidateToken(OLD_TOKEN) + mockUserAccountManager.updateAccount(mockAccount, capture(userSlot)) + } + Assert.assertEquals(REFRESHED_TOKEN, userSlot.captured.authToken) + } + + @Test + fun testGetNewAuthToken_Revoked() { + every { HttpAccess.DEFAULT.okHttpClient } returns mockk { + every { newCall(any()) } returns mockk { + every { execute() } returns mockk(relaxed = true) { + every { isSuccessful } returns false + } + } + } + val accountSlot = slot() + val reasonSlot = slot() + val broadcastIntentSlot = slot() + val mockAccount = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + + // Use the real clientManager instead of a full mock because revokedTokenShouldLogout is private. + val clientManagerSpy = spyk(clientManager) + every { clientManagerSpy.accounts } returns arrayOf(mockAccount) + every { mockUserAccountManager.currentUser } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + every { mockUserAccountManager.updateAccount(mockAccount, any()) } returns mockk() + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + clientManagerSpy, + "https://login.salesforce.com", + OLD_TOKEN, + "", + ) + + Assert.assertNull(authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockUserAccountManager.updateAccount(any(), any()) + } + verify(exactly = 1) { + clientManagerSpy.invalidateToken(OLD_TOKEN) + mockSDKManager.logout(capture(accountSlot), any(), any(), capture(reasonSlot)) + mockAppContext.sendBroadcast(capture(broadcastIntentSlot)) + } + Assert.assertEquals(mockAccount, accountSlot.captured) + Assert.assertEquals(OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED, reasonSlot.captured) + Assert.assertEquals(ClientManager.ACCESS_TOKEN_REVOKE_INTENT, broadcastIntentSlot.captured.action) + } + + /* + Non-current user tests the scenario of attempting to make a + network call as the previous user on user account switch, but + requiring a token refresh. + */ + @Test + fun testGetNewAuthToken_Multiuser_NonCurrentUser() { + val user2Token = "user2-token" + val userSlot = slot() + val mockAccount = mockk(relaxed = true) + val mockAccount2 = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + val mockUser2 = mockk(relaxed = true) { + every { authToken } returns user2Token + every { loginServer } returns "https://login.salesforce.com" + } + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns arrayOf(mockAccount, mockAccount2) + } + // The account that we are not refreshing for is the current account. + every { mockUserAccountManager.currentUser } returns mockUser2 + every { mockUserAccountManager.currentAccount } returns mockAccount2 + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount2) } returns mockUser2 + every { mockUserAccountManager.updateAccount(mockAccount, any()) } returns mockk() + every { mockUserAccountManager.updateAccount(mockAccount2, any()) } returns mockk() + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + mockClientManager, + "https://login.salesforce.com", + OLD_TOKEN, + "", + ) + + Assert.assertEquals(REFRESHED_TOKEN, authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockClientManager.invalidateToken(user2Token) + mockSDKManager.logout(any(), any(), any(), any()) + mockUserAccountManager.updateAccount(mockAccount2, any()) + } + verify(exactly = 1) { + mockClientManager.invalidateToken(OLD_TOKEN) + mockUserAccountManager.updateAccount(mockAccount, capture(userSlot)) + } + Assert.assertEquals(REFRESHED_TOKEN, userSlot.captured.authToken) + } + + /* + Non-current user tests the scenario of attempting to make a + network call as the previous user on user account switch, but + requiring a token refresh. + */ + @Test + fun testGetNewAuthToken_Multiuser_RevokeNonCurrentUser() { + every { HttpAccess.DEFAULT.okHttpClient } returns mockk { + every { newCall(any()) } returns mockk { + every { execute() } returns mockk(relaxed = true) { + every { isSuccessful } returns false + } + } + } + val accountSlot = slot() + val reasonSlot = slot() + val broadcastIntentSlot = slot() + val user2Token = "user2-token" + val mockAccount = mockk(relaxed = true) + val mockAccount2 = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + val mockUser2 = mockk(relaxed = true) { + every { authToken } returns user2Token + every { loginServer } returns "https://login.salesforce.com" + } + val mockClientManager = mockk(relaxed = true) { + every { accounts } returns arrayOf(mockAccount, mockAccount2) + } + // The account that we are not refreshing for is the current account. + every { mockUserAccountManager.currentUser } returns mockUser2 + every { mockUserAccountManager.currentAccount } returns mockAccount2 + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount2) } returns mockUser2 + every { mockUserAccountManager.updateAccount(mockAccount, any()) } returns mockk() + every { mockUserAccountManager.updateAccount(mockAccount2, any()) } returns mockk() + // Use the real clientManager instead of a full mock because revokedTokenShouldLogout is private. + val clientManagerSpy = spyk(clientManager) + every { clientManagerSpy.accounts } returns arrayOf(mockAccount) + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + clientManagerSpy, + "https://login.salesforce.com", + OLD_TOKEN, + "", + ) + + Assert.assertNull(authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockClientManager.invalidateToken(user2Token) + mockUserAccountManager.updateAccount(any(), any()) + mockSDKManager.logout(mockAccount2, any(), any(), any()) + mockSDKManager.logout(null, any(), any(), any()) + mockUserAccountManager.updateAccount(mockAccount2, any()) + } + + verify(exactly = 1) { + clientManagerSpy.invalidateToken(OLD_TOKEN) + mockSDKManager.logout(capture(accountSlot), any(), any(), capture(reasonSlot)) + mockAppContext.sendBroadcast(capture(broadcastIntentSlot)) + } + Assert.assertEquals(mockAccount, accountSlot.captured) + Assert.assertEquals(OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED, reasonSlot.captured) + Assert.assertEquals(ClientManager.ACCESS_TOKEN_REVOKE_INTENT, broadcastIntentSlot.captured.action) + } +} +