From 5cecb66e8287905483552033b13aa3a5818b53d2 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Wed, 17 Jun 2026 16:56:48 -0600 Subject: [PATCH 1/2] @W-22699714: [Android] Improve error handling at code exchange Add user-friendly 'app blocked' toast when code exchange fails with client_blocked error. Follows same pattern as existing Lightning URL error handling in LoginActivity.onAuthFlowError(). - Add sf__app_blocked_error string resource - Add isClientBlocked check + when expression for toast message - Add 3 doCodeExchange error path tests in LoginViewModelMockTest - Add 3 onAuthFlowError integration tests in LoginActivityAuthErrorTest --- libs/SalesforceSDK/res/values/sf__strings.xml | 1 + .../salesforce/androidsdk/ui/LoginActivity.kt | 13 +- .../SalesforceSDKTest/AndroidManifest.xml | 5 + .../auth/LoginActivityAuthErrorTest.kt | 226 ++++++++++++++++++ .../androidsdk/auth/LoginViewModelMockTest.kt | 101 ++++++++ 5 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml index dd759506a4..ec46f7ee23 100644 --- a/libs/SalesforceSDK/res/values/sf__strings.xml +++ b/libs/SalesforceSDK/res/values/sf__strings.xml @@ -11,6 +11,7 @@ Authentication only allowed from managed device. JWT authentication error. Please try again. Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead. + This app has been blocked. Contact your administrator for assistance. SSL error: %s. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 4069244d45..ad4c2c314c 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -109,6 +109,7 @@ import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error +import com.salesforce.androidsdk.R.string.sf__app_blocked_error import com.salesforce.androidsdk.R.string.sf__lightning_url_code_exchange_error import com.salesforce.androidsdk.R.string.sf__login_with_biometric import com.salesforce.androidsdk.R.string.sf__screen_lock_error @@ -125,6 +126,7 @@ import com.salesforce.androidsdk.app.Features.FEATURE_WELCOME_DISCOVERY_LOGIN import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.auth.HttpAccess +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens @@ -584,6 +586,8 @@ open class LoginActivity : FragmentActivity() { ) viewModel.clearCookies() + val isClientBlocked = e is OAuthFailedException + && e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR val isLightningTokenEndpointFailure = e is OAuthFailedException && e.tokenErrorResponse.error == "unsupported_grant_type" && viewModel.selectedServer.value?.contains(".lightning.") == true @@ -592,11 +596,12 @@ open class LoginActivity : FragmentActivity() { } // Displays the error in a toast, clears cookies and reloads the login page runOnUiThread { - if (isLightningTokenEndpointFailure) { - makeText(this, getString(sf__lightning_url_code_exchange_error), LENGTH_LONG).show() - } else { - makeText(this, "$error : $errorDesc", LENGTH_LONG).show() + val toastMessage = when { + isClientBlocked -> getString(sf__app_blocked_error) + isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error) + else -> "$error : $errorDesc" } + makeText(this, toastMessage, LENGTH_LONG).show() viewModel.reloadWebView() } } diff --git a/libs/test/SalesforceSDKTest/AndroidManifest.xml b/libs/test/SalesforceSDKTest/AndroidManifest.xml index 578ceabb97..76fce56b14 100644 --- a/libs/test/SalesforceSDKTest/AndroidManifest.xml +++ b/libs/test/SalesforceSDKTest/AndroidManifest.xml @@ -20,6 +20,11 @@ + + + (relaxed = true) + every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR + every { tokenErrorResponse.errorDescription } returns "App is blocked by admin" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + val latch = CountDownLatch(1) + var receivedIntent: Intent? = null + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + receivedIntent = intent + latch.countDown() + } + } + + val context: Context = getApplicationContext() + context.registerReceiver( + receiver, + IntentFilter(AUTHENTICATION_FAILED_INTENT), + Context.RECEIVER_EXPORTED + ) + + try { + launch( + Intent(context, TestLoginActivity::class.java) + ).use { activityScenario -> + activityScenario.onActivity { activity -> + activity.viewModel.onWebServerFlowComplete( + "test_code", + { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, + { }, + ) + } + + assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS)) + assertNotNull(receivedIntent) + assertEquals(403, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0)) + assertEquals(CLIENT_BLOCKED_ERROR, receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT)) + assertEquals("App is blocked by admin", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT)) + } + } finally { + context.unregisterReceiver(receiver) + } + } + + @Test + fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() { + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR + every { tokenErrorResponse.errorDescription } returns "App is blocked" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + launch( + Intent(getApplicationContext(), TestLoginActivity::class.java) + ).use { activityScenario -> + activityScenario.onActivity { activity -> + activity.viewModel.onWebServerFlowComplete( + "test_code", + { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, + { }, + ) + } + + // Allow time for the coroutine + runOnUiThread to complete + Thread.sleep(500) + + activityScenario.onActivity { activity -> + val expectedMessage = activity.getString( + com.salesforce.androidsdk.R.string.sf__app_blocked_error + ) + assertEquals( + "This app has been blocked. Contact your administrator for assistance.", + expectedMessage + ) + } + } + } + + @Test + fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() { + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns "invalid_grant" + every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + val oauthException = OAuthFailedException(tokenErrorResponse, 400) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + val latch = CountDownLatch(1) + var receivedIntent: Intent? = null + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + receivedIntent = intent + latch.countDown() + } + } + + val context: Context = getApplicationContext() + context.registerReceiver( + receiver, + IntentFilter(AUTHENTICATION_FAILED_INTENT), + Context.RECEIVER_EXPORTED + ) + + try { + launch( + Intent(context, TestLoginActivity::class.java) + ).use { activityScenario -> + activityScenario.onActivity { activity -> + activity.viewModel.onWebServerFlowComplete( + "test_code", + { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, + { }, + ) + } + + assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS)) + assertNotNull(receivedIntent) + assertEquals(400, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0)) + assertEquals("invalid_grant", receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT)) + assertEquals("Expired authorization code", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT)) + } + } finally { + context.unregisterReceiver(receiver) + } + } +} 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 e8265a6051..bda7583515 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -787,6 +787,107 @@ class LoginViewModelMockTest { // endregion + // region doCodeExchange Error Path Tests + + @Test + fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns OAuth2.CLIENT_BLOCKED_ERROR + every { tokenErrorResponse.errorDescription } returns "App is blocked" + val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + Thread.sleep(200) + + verify { + mockOnError("Token Request Error", any(), oauthException) + } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + + val ioException = java.io.IOException("Network error") + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws ioException + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + Thread.sleep(200) + + verify { + mockOnError("Token Request Error", "Network error", ioException) + } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns "invalid_grant" + every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + coEvery { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } just runs + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + Thread.sleep(200) + + coVerify(exactly = 0) { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } + } + + // endregion + // region showBiometricAuthenticationButton Tests @Test From 0c9a8b8d80d63e78ca142bc508ddb02a5ea07a8e Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Wed, 17 Jun 2026 17:46:13 -0600 Subject: [PATCH 2/2] fix(test): correct MockK static mock pattern for OAuth2.exchangeCode TokenErrorResponse.error and .errorDescription are public Java fields, not methods. MockK's every { } block only intercepts method calls, so using every { mock.error } returns value triggers "Missing mocked calls inside every { ... } block". Fix by directly assigning the fields on the relaxed mock instance instead. --- .../androidsdk/auth/LoginActivityAuthErrorTest.kt | 12 ++++++------ .../androidsdk/auth/LoginViewModelMockTest.kt | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt index d0fef89ae8..6b37ed60c7 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt @@ -88,8 +88,8 @@ class LoginActivityAuthErrorTest { @Test fun onAuthFlowError_givenClientBlocked_broadcastsWithCorrectExtras() { val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR - every { tokenErrorResponse.errorDescription } returns "App is blocked by admin" + tokenErrorResponse.error = CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked by admin" val oauthException = OAuthFailedException(tokenErrorResponse, 403) every { @@ -139,8 +139,8 @@ class LoginActivityAuthErrorTest { @Test fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() { val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR - every { tokenErrorResponse.errorDescription } returns "App is blocked" + tokenErrorResponse.error = CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked" val oauthException = OAuthFailedException(tokenErrorResponse, 403) every { @@ -176,8 +176,8 @@ class LoginActivityAuthErrorTest { @Test fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() { val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns "invalid_grant" - every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + tokenErrorResponse.error = "invalid_grant" + tokenErrorResponse.errorDescription = "Expired authorization code" val oauthException = OAuthFailedException(tokenErrorResponse, 400) every { 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 bda7583515..be59e4ed77 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -802,8 +802,8 @@ class LoginViewModelMockTest { mockkStatic(OAuth2::class) val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns OAuth2.CLIENT_BLOCKED_ERROR - every { tokenErrorResponse.errorDescription } returns "App is blocked" + tokenErrorResponse.error = OAuth2.CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked" val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403) every { @@ -863,8 +863,8 @@ class LoginViewModelMockTest { mockkStatic(OAuth2::class) val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns "invalid_grant" - every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + tokenErrorResponse.error = "invalid_grant" + tokenErrorResponse.errorDescription = "Expired authorization code" val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400) every {