From f9a47a3869b0c104466c3879c9ce9a55745f6962 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 12 Aug 2026 17:30:59 -0700 Subject: [PATCH 01/12] fix(W-23836447): allow dpop_jkt on login pool servers Pool servers (login.salesforce.com, test.salesforce.com) now support DPoP authorization code binding. Remove the isPoolServer guard from addDpopJktIfNeeded() so dpop_jkt is sent for all login servers when DPoP is enabled. - Add pool-server unit test to LoginViewModelTest - Add loginPoolHost / requireLoginPoolHost() to UITestConfig - Add useLoginPoolHost param to AuthFlowTest.loginAndValidate - Add testECAJwtDPoP_ViaLoginPoolServer to DPoPLoginTests - Update RefreshTokenMigrationTests override to match new signature --- CLAUDE.md | 26 ++++++++++++++++++- .../androidsdk/ui/LoginViewModel.kt | 9 +++---- .../androidsdk/auth/LoginViewModelTest.kt | 22 ++++++++++++++++ .../samples/authflowtester/DPoPLoginTests.kt | 19 ++++++++++++++ .../RefreshTokenMigrationTests.kt | 2 ++ .../testUtility/AuthFlowTest.kt | 7 +++++ .../testUtility/UITestConfig.kt | 9 ++++++- 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ffe5b9772..ec8ac41687 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,11 +67,35 @@ See [README.md](README.md) for basic setup. Commands below are for contributors ./gradlew :libs:SmartStore:build ./gradlew :libs:MobileSync:build -# Run tests for a specific library (runs on Firebase Test Lab in CI) +# Run instrumented tests for a specific library on a connected emulator/device +# Note: SalesforceSDKTest sources live in libs/test/SalesforceSDKTest but are wired into +# :libs:SalesforceSDK's androidTest source set via build.gradle.kts setRoot(). +# Run them via :libs:SalesforceSDK:connectedAndroidTest, NOT a separate project. ./gradlew :libs:SalesforceSDK:connectedAndroidTest ./gradlew :libs:SmartStore:connectedAndroidTest ./gradlew :libs:MobileSync:connectedAndroidTest +# Run a single test class on the emulator +./gradlew :libs:SalesforceSDK:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.salesforce.androidsdk.auth.LoginViewModelTest + +# Run a single test method on the emulator +./gradlew :libs:SalesforceSDK:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.salesforce.androidsdk.auth.LoginViewModelTest#generateAuthorizationUrl_WhenUseDPoP_AndPoolServer_AddsDpopJktToUrl + +# Run AuthFlowTester UI tests on emulator (requires ui_test_config.json in shared/test/ with valid org credentials) +# The emulator CAN reach internal test environments when the machine has VPN/network access. +# First verify emulator is running: adb devices +./gradlew :native:NativeSampleApps:AuthFlowTester:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.salesforce.samples.authflowtester.DPoPLoginTests + +# Run a single AuthFlowTester UI test method +./gradlew :native:NativeSampleApps:AuthFlowTester:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.salesforce.samples.authflowtester.DPoPLoginTests#testECAJwtDPoP_ViaLoginPoolServer + +# Run all AuthFlowTester UI tests +./gradlew :native:NativeSampleApps:AuthFlowTester:connectedAndroidTest + # Run lint checks ./gradlew :libs:SalesforceSDK:lint ./gradlew :libs:SmartStore:lint diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 78343eeec3..5b7ecc4a5a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -697,17 +697,16 @@ open class LoginViewModel( // endregion /** - * Adds `dpop_jkt` to [params] when DPoP is enabled and [server] is a my-domain server. - * Pool servers (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) do not - * support DPoP code binding and reject the parameter. + * Adds `dpop_jkt` to [params] when DPoP is enabled. + * welcome.salesforce.com/discovery is never passed here — discovery resolves a my-domain + * server before /authorize is called. */ private fun addDpopJktIfNeeded( server: String, sdkManager: SalesforceSDKManager, params: MutableMap, ) { - val isMyDomainServer = !LoginServerManager.isPoolServer(server) - if (!sdkManager.useDPoP || !isMyDomainServer) { + if (!sdkManager.useDPoP) { // Clear any stale dpop_jkt and its key from a previous server-picker entry. params.remove("dpop_jkt") pendingCredentialsIdentifier?.let { 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 1fa13e3502..8a2131417e 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -493,6 +493,28 @@ class LoginViewModelTest { } } + @Test + fun generateAuthorizationUrl_WhenUseDPoP_AndPoolServer_AddsDpopJktToUrl() = runBlocking { + // dpop_jkt must be sent for pool servers when useDPoP=true. + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns true + + viewModel.generateAuthorizationUrl("https://login.salesforce.com", sdkManagerMock) + val url = viewModel.loginUrl.value ?: "" + assert(url.contains("dpop_jkt=")) { + "Expected dpop_jkt in authorization URL for pool server when useDPoP=true, got: $url" + } + val thumbprint = url.toUri().getQueryParameter("dpop_jkt") ?: "" + assert(thumbprint.matches(Regex("[A-Za-z0-9_-]{43}"))) { + "dpop_jkt must be 43-char base64url RFC 7638 thumbprint, got: '$thumbprint'" + } + } + // endregion // region frontDoorBridgeUrl Tests diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt index 1502af65cd..3f1a724a9f 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt @@ -221,6 +221,25 @@ class DPoPLoginTests : AuthFlowTest() { // endregion + // endregion + + // region DPoP Pool Server Tests + + // Login via the pool server (login.test1.pc-rnd.salesforce.com) with DPoP enabled + // and verify dpop_jkt was accepted and DPoP binding holds after a revoke+refresh. + @Test + fun testECAJwtDPoP_ViaLoginPoolServer() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + useHybridAuthToken = false, + useDPoP = true, + useLoginPoolHost = true, + ) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isJwt = true) + } + + // endregion + // region DPoP Login for Admins Tests // Login for Admins with DPoP ECA; verifies the admin Custom Tab hand-off works with DPoP. diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt index 10d1989a13..c17433c286 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt @@ -227,6 +227,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { forceAdvancedAuthentication: Boolean, useWelcomeDiscovery: Boolean, isMultiUser: Boolean, + useLoginPoolHost: Boolean, ) { super.loginAndValidate( knownAppConfig = knownAppConfig, @@ -239,6 +240,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { knownUserConfig = user, useWelcomeDiscovery = useWelcomeDiscovery, isMultiUser = isMultiUser, + useLoginPoolHost = useLoginPoolHost, ) } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index a3472911e6..5d2269df31 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -225,6 +225,7 @@ abstract class AuthFlowTest { forceAdvancedAuthentication: Boolean = true, useWelcomeDiscovery: Boolean = false, isMultiUser: Boolean = false, + useLoginPoolHost: Boolean = false, ) { // When forceAdvancedAuthentication is true (default) every login completes in a Custom Tab: // a ChromeCustomTabPageObject serves both roles — its inherited Compose actions @@ -308,6 +309,12 @@ abstract class AuthFlowTest { loginPage.backOutToLoginActivity() loginPage.changeServerByUrl(WELCOME_DISCOVERY_URL) authenticationPage.welcomeLogin(knownLoginHostConfig, knownUserConfig) + } else if (useLoginPoolHost) { + // Use the pool server URL from ui_test_config.json for the login host. + // Credentials are taken from knownLoginHostConfig — same org, different login entry point. + loginPage.backOutToLoginActivity() + loginPage.changeServerByUrl(testConfig.requireLoginPoolHost()) + authenticationPage.login(knownLoginHostConfig, knownUserConfig) } else { if (knownLoginHostConfig != REGULAR_AUTH) { // Switching servers is a top-bar action, so surface LoginActivity first. Selecting diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt index 575757b23d..1bab3bb710 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt @@ -75,7 +75,14 @@ val testConfig: UITestConfig by lazy { } @Serializable -data class UITestConfig(val loginHosts: List, val apps: List) { +data class UITestConfig( + val loginPoolHost: String? = null, + val loginHosts: List, + val apps: List, +) { + + fun requireLoginPoolHost(): String = loginPoolHost + ?: throw Exception("loginPoolHost not found in ui_test_config.json.") fun getLoginHost(knownLoginHostConfig: KnownLoginHostConfig): LoginHost = loginHosts.find { (name, _, _) -> name == knownLoginHostConfig.name.toLowerCase(Locale.current) From c2b6fc5c2a55e4a939e255972319ff344e015f4d Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 12 Aug 2026 19:07:59 -0700 Subject: [PATCH 02/12] fix(test): handle non-dismissable login picker in Chrome Custom Tab page object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since PR #2983, the login-server picker is modal and non-dismissable — it has no close button, and the top app bar's overflow menu is behind its scrim. Tests that called backOutToLoginActivity() followed by openLoginOptions() were failing because openLoginOptions() tried to tap MORE_OPTIONS_BUTTON while the picker was covering it. Fix: backOutToLoginActivity() now simply closes the Chrome tab and waits for either the picker or the top bar to be reachable (whichever the SDK raises). openLoginOptions() then detects whether the picker is showing and, if so, routes through PICKER_DEV_SUPPORT_BUTTON (the debug-only button in the picker's own header) instead of the obscured top-bar path. --- .../pageObjects/ChromeCustomTabPageObject.kt | 42 +++++++------------ .../pageObjects/LoginPageObject.kt | 27 ++++++++---- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt index 2baf7cc832..6d00526722 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt @@ -28,13 +28,11 @@ package com.salesforce.samples.authflowtester.pageObjects import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onAllNodesWithContentDescription -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.onAllNodesWithTag import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector -import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.ui.components.LoginViewTestTags import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig @@ -85,15 +83,14 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje } /** - * Surfaces the LoginActivity by backing out of the Custom Tab that forced advanced auth - * auto-launches over it (the tab hides the Compose top bar the [LoginPageObject] actions need). - * Backing out returns `RESULT_CANCELED`, which the SDK handles by raising the server-picker - * bottom sheet; that is dismissed here too so a subsequent top-bar action can re-launch the tab. - * Waits up to [TIMEOUT_MS] for the async tab launch; no-op if no tab appears (already on the - * LoginActivity). + * Surfaces the LoginActivity (or the server picker) by closing the Custom Tab that forced + * advanced auth auto-launches over it. Since W-23731759 the login picker is non-dismissable, + * so callers that need the top bar (e.g. [changeServerByUrl]) must select a server from the + * picker first; callers that need Login Options can use the picker's dev-support button via + * [LoginPageObject.openLoginOptions]. This method only closes the tab and waits for Compose + * to be ready — it does NOT attempt to dismiss the picker. */ override fun backOutToLoginActivity() { - // Clear the FRE first; until it is gone it covers the tab toolbar (the close button). skipGoogleSignIn() val closeButton = device.findObject( UiSelector().resourceId("com.android.chrome:id/close_button") @@ -102,27 +99,18 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje return } closeButton.click() - dismissServerPickerIfPresent() - } - - /** Dismisses the login-server-picker bottom sheet via its Close button, if it is showing. */ - private fun dismissServerPickerIfPresent() { - val closeDescription = getString(R.string.sf__server_close_button_content_description) - val appeared = try { + // Wait for either the picker or the top bar to be reachable. + try { composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - composeTestRule.onAllNodesWithContentDescription(closeDescription) + composeTestRule.onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes().isNotEmpty() || + composeTestRule.onAllNodesWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) .fetchSemanticsNodes().isNotEmpty() } - true } catch (_: ComposeTimeoutException) { - // The picker never appeared (e.g. shared browser session re-auth); nothing to dismiss. - false - } - - if (appeared) { - composeTestRule.onNodeWithContentDescription(closeDescription).performClick() - composeTestRule.waitForIdle() + // Best-effort; caller action will fail with a clear message if neither is reachable. } + composeTestRule.waitForIdle() } override fun setUsername(name: String) { diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index 333c70567a..40caf620ea 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -116,14 +116,27 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com } fun openLoginOptions() { - // Tap "More Options" three-dot menu (Compose IconButton) - composeTestRule.onNodeWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) - .performClick() - composeTestRule.waitForIdle() + // If the login-server picker is showing, the top app bar is behind its modal scrim. + // In that case, tap the picker's own dev-support button instead (PICKER_DEV_SUPPORT_BUTTON + // is visible in the picker header for debug builds). Otherwise use the normal top-bar path. + val pickerShowing = composeTestRule + .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes() + .isNotEmpty() - // Tap "Developer Support" dropdown menu item - composeTestRule.onNodeWithTag(LoginViewTestTags.MENU_ITEM_DEV_SUPPORT) - .performClick() + if (pickerShowing) { + composeTestRule.onNodeWithTag(LoginViewTestTags.PICKER_DEV_SUPPORT_BUTTON) + .performClick() + } else { + // Tap "More Options" three-dot menu (Compose IconButton) + composeTestRule.onNodeWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) + .performClick() + composeTestRule.waitForIdle() + + // Tap "Developer Support" dropdown menu item + composeTestRule.onNodeWithTag(LoginViewTestTags.MENU_ITEM_DEV_SUPPORT) + .performClick() + } composeTestRule.waitForIdle() // Wait for the AlertDialog to be fully rendered and ready From 491ede926893550f2429915d0a96f4d1b7323ca1 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 12 Aug 2026 19:10:55 -0700 Subject: [PATCH 03/12] fix(test): correct L-marker for pool-server logins; add non-DPoP pool test Pool servers (login.*.salesforce.com) register the L1 (Production) feature flag, not L4 (MyDomain). Fix loginAndValidate() to emit L1 when useLoginPoolHost is true. Also add testECAJwt_ViaLoginPoolServer to ECALoginTests so there is a non-DPoP baseline test for pool-server login, alongside the DPoP variant in DPoPLoginTests. --- .../salesforce/samples/authflowtester/ECALoginTests.kt | 10 ++++++++++ .../samples/authflowtester/testUtility/AuthFlowTest.kt | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt index 8423c78b8d..0516bb213e 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt @@ -83,4 +83,14 @@ fun testECAJwt_SubsetScopes_NotHybrid() { fun testECAJwt_AllScopes() { loginAndValidate(knownAppConfig = ECA_JWT, scopeSelection = ALL) } + + // region ECA Pool Server Tests + + // Login via the pool server without DPoP and verify the session is valid. + @Test + fun testECAJwt_ViaLoginPoolServer() { + loginAndValidate(knownAppConfig = ECA_JWT, useLoginPoolHost = true) + } + + // endregion } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 5d2269df31..bd443fa882 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -336,10 +336,11 @@ abstract class AuthFlowTest { Features.FEATURE_BROWSER_LOGIN_SERVER_AUTH_CONFIG else -> null } - val expectedLMarker = if (useWelcomeDiscovery) { - Features.FEATURE_LOGIN_SERVER_WELCOME_DISCOVERY - } else { - Features.FEATURE_LOGIN_SERVER_MY_DOMAIN + val expectedLMarker = when { + useWelcomeDiscovery -> Features.FEATURE_LOGIN_SERVER_WELCOME_DISCOVERY + // Pool server (login.salesforce.com, login.*.salesforce.com) registers L1, not L4. + useLoginPoolHost -> Features.FEATURE_LOGIN_SERVER_PRODUCTION + else -> Features.FEATURE_LOGIN_SERVER_MY_DOMAIN } val expectedAMarker = when { useWebServerFlow && useHybridAuthToken -> Features.FEATURE_AUTH_TYPE_WEB_SERVER_HYBRID From 9b809846d2aee261d5ca3cc703a5d1b02c87c41f Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 12 Aug 2026 19:15:34 -0700 Subject: [PATCH 04/12] fix(test): handle pre-shown picker in changeServerByUrl; add pool server to servers.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changeServerByUrl() now detects if the login-server picker is already visible (left open by backOutToLoginActivity() after a tab close) and skips the MORE_OPTIONS_BUTTON → MENU_ITEM_PICK_SERVER path, which is blocked by the picker's modal scrim. If already showing, it selects the server directly. Also adds the UITests Login Pool server to servers.xml so the picker can find and select `login.test1.pc-rnd.salesforce.com` during pool-server tests. --- .../pageObjects/LoginPageObject.kt | 41 ++++++++++++------- .../src/main/res/xml/servers.xml | 1 + 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index 40caf620ea..4754c5cce5 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -217,26 +217,37 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com /** * Selects a server from the server picker bottom sheet by matching its URL substring. * Used for servers that aren't represented in `ui_test_config.json` (e.g. - * `welcome.salesforce.com/discovery`). + * `welcome.salesforce.com/discovery`) and for pool-server login. + * + * If the picker is already showing (e.g. because [backOutToLoginActivity] left it up after + * the tab closed), skip opening it and select directly. This handles the non-dismissable + * picker introduced in W-23731759. */ fun changeServerByUrl(url: String) { - // Tap "More Options" three-dot menu (Compose IconButton) - composeTestRule.onNodeWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) - .performClick() - composeTestRule.waitForIdle() + val pickerAlreadyShowing = composeTestRule + .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes() + .isNotEmpty() - // Tap "Change Server" dropdown menu item - composeTestRule.onNodeWithTag(LoginViewTestTags.MENU_ITEM_PICK_SERVER) - .performClick() + if (!pickerAlreadyShowing) { + // Tap "More Options" three-dot menu (Compose IconButton) + composeTestRule.onNodeWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) + .performClick() + composeTestRule.waitForIdle() - // Wait for server picker bottom sheet to appear - try { - composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - composeTestRule.onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) - .fetchSemanticsNodes().isNotEmpty() + // Tap "Change Server" dropdown menu item + composeTestRule.onNodeWithTag(LoginViewTestTags.MENU_ITEM_PICK_SERVER) + .performClick() + + // Wait for server picker bottom sheet to appear + try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + composeTestRule.onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes().isNotEmpty() + } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting for server picker bottom sheet to appear", e) } - } catch (e: ComposeTimeoutException) { - throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting for server picker bottom sheet to appear", e) } // Select the server matching the URL (filter for clickable node if multiple matches) diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml index 43f9aa8221..43d6864d96 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml @@ -4,6 +4,7 @@ + From d0680a02ad6a9466febe5604c3062119657dbdb9 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 13 Aug 2026 07:56:54 -0700 Subject: [PATCH 05/12] fix(test): dismiss picker in ensureRegularAuthServer when WebView path needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After backOutToLoginActivity() the server picker can be left showing. ensureRegularAuthServer() was calling setSelectedLoginServer() directly, which doesn't close the picker UI — leaving it covering the WebView for in-app WebView tests (User Agent Flow, forceAdvancedAuthentication=false). Fix: detect whether the picker is showing; if so, dismiss it via changeServerByUrl() (tapping the row triggers reloadWebView and closes the sheet) rather than calling setSelectedLoginServer() directly. --- .../authflowtester/testUtility/AuthFlowTest.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index bd443fa882..967c3ac02f 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -30,7 +30,9 @@ import android.Manifest import android.content.Intent import android.os.Build import androidx.annotation.VisibleForTesting +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.junit4.createEmptyComposeRule +import com.salesforce.androidsdk.ui.components.LoginViewTestTags import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule @@ -198,7 +200,19 @@ abstract class AuthFlowTest { // login surface. val regularAuthServer = loginServerManager.getLoginServerFromURL(regularAuthUrl) if (regularAuthServer != null) { - loginServerManager.setSelectedLoginServer(regularAuthServer) + // If the picker is showing after backOutToLoginActivity(), dismiss it by tapping the + // regular-auth row — this triggers reloadWebView and closes the sheet. When the picker + // is not showing, call setSelectedLoginServer directly as before. + val loginPage = LoginPageObject(composeTestRule) + val pickerShowing = composeTestRule + .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes() + .isNotEmpty() + if (pickerShowing) { + loginPage.changeServerByUrl(regularAuthUrl) + } else { + loginServerManager.setSelectedLoginServer(regularAuthServer) + } if (expectCustomTab) { // Reaching here means the server actually changed (a sticky ADVANCED_AUTH From 3ad18b0e10f17e78a5c8432fd91a2e77f70fbc44 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 13 Aug 2026 11:21:26 -0700 Subject: [PATCH 06/12] fix(test): fix WebView timing after server-picker dismissal - Increased TIMEOUT_MS from 10/15s to 15/20s (local/FTL) and added WEBVIEW_ACTION_TIMEOUT_MS (45s local / 60s FTL) in BasePageObject to handle "Atom evaluation returned null" failures when the login form takes longer than the old 10s budget to render after a server-picker dismissal triggers a WebView reload. - Added waitForPageLoad() in LoginPageObject (watches LOADING_INDICATOR appear/disappear) and waitForLoginScreen() (waits for MORE_OPTIONS_BUTTON); login() now calls waitForPageLoad() before setting the username and uses WEBVIEW_ACTION_TIMEOUT_MS for the retry budget. - In AuthFlowTest.ensureRegularAuthServer(), after changeServerByUrl() for the picker-is-showing path, calls loginPage.waitForLoginScreen() to ensure the Compose hierarchy is fully settled before returning. - Fixed UITestConfig Json parser to use Json { ignoreUnknownKeys = true } so unknown fields in ui_test_config.json do not crash parsing on this branch. --- .../pageObjects/BasePageObject.kt | 12 ++- .../pageObjects/LoginPageObject.kt | 79 ++++++++++++++++++- .../testUtility/AuthFlowTest.kt | 9 ++- .../testUtility/UITestConfig.kt | 4 +- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/BasePageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/BasePageObject.kt index 4e36c616dc..5c970f6d31 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/BasePageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/BasePageObject.kt @@ -44,11 +44,21 @@ abstract class BasePageObject(val composeTestRule: ComposeTestRule) { ) == "true" } val TIMEOUT_MS: Long by lazy { - if (isFtl) 15_000 else 10_000 + if (isFtl) 20_000 else 15_000 } val SLEEP_TIME_MS: Long by lazy { if (isFtl) 5_000 else 2_500 } + + /** + * Extended timeout for Espresso WebView actions ([retryWebAction]) that wait for + * server-rendered login page content. The Salesforce sandbox login page can take up to + * ~30 s to render interactive form elements after [onPageFinished] fires; this budget + * covers that latency with headroom for both local emulators and Firebase Test Lab. + */ + val WEBVIEW_ACTION_TIMEOUT_MS: Long by lazy { + if (isFtl) 60_000 else 45_000 + } } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index 3f454923ec..47f3ab98e9 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -80,7 +80,12 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com open fun login(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { val (username, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) - setUsername(username) + waitForPageLoad() + retryWebAction(timeoutMs = WEBVIEW_ACTION_TIMEOUT_MS) { + onWebView().withElement(findElement(Locator.ID, USERNAME_ID)) + .perform(clearElement()) + .perform(webKeys(username)) + } tapLogin() setPassword(password) tapLogin() @@ -102,6 +107,76 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com false } + /** + * Waits for the LoginActivity top bar to be visible (MORE_OPTIONS_BUTTON present and Compose + * idle). Used after [changeServerByUrl] dismisses the server picker: the picker close and + * subsequent WebView reload are asynchronous; waiting here ensures the LoginActivity Compose + * hierarchy is fully settled before the caller proceeds. + */ + fun waitForLoginScreen() { + try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + composeTestRule + .onAllNodesWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) + .fetchSemanticsNodes() + .isNotEmpty() + } + } catch (_: androidx.compose.ui.test.ComposeTimeoutException) { + // Best-effort: if the top bar is not reachable within the timeout the caller's + // subsequent actions will fail with descriptive messages. + } + } + + /** + * Waits for the in-app WebView page to finish loading by watching the LOADING_INDICATOR: + * phase 1 waits for the indicator to appear (confirming a reload has started); phase 2 waits + * for it to disappear (confirming [LoginActivity.LoginWebViewClient.onPageFinished] has fired). + * + * This is called at the top of [login] to handle the case where a reload was triggered by + * [LoginOptionsPageObject.setOverrideBootConfig] → [LoginOptionsActivity.finish()] → + * [LoginActivity.onResume] → [LoginViewModel.reloadWebView]. The Salesforce sandbox page can + * take 20–30 s to render the login form, and [retryWebAction]'s 15 s budget would expire + * before the `username` element appears without this explicit wait. + * + * Best-effort on both phases so slow or already-loaded pages degrade gracefully: if the + * indicator never appears the page was already loaded (or loaded faster than we checked) and + * we proceed immediately; if phase 2 times out [retryWebAction] keeps retrying as a fallback. + */ + private fun waitForPageLoad() { + // Phase 1: Wait for the LOADING_INDICATOR to appear, confirming a reload is in progress. + // Short timeout: if the reload was so fast we missed the indicator, proceed immediately. + val indicatorAppeared = try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + composeTestRule + .onAllNodesWithTag(LoginViewTestTags.LOADING_INDICATOR) + .fetchSemanticsNodes() + .isNotEmpty() + } + true + } catch (_: ComposeTimeoutException) { + false + } + + if (!indicatorAppeared) { + // The indicator never showed: page was already loaded or the reload is still pending. + // Proceed — retryWebAction will handle any remaining wait. + return + } + + // Phase 2: Wait for the LOADING_INDICATOR to disappear, confirming onPageFinished fired. + // Use a generous multiple of TIMEOUT_MS to accommodate slow sandbox pages (observed ~26 s). + try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS * 3) { + composeTestRule + .onAllNodesWithTag(LoginViewTestTags.LOADING_INDICATOR) + .fetchSemanticsNodes() + .isEmpty() + } + } catch (_: ComposeTimeoutException) { + // Best-effort: page is still loading but retryWebAction will keep trying. + } + } + /** * Welcome Discovery login: the OAuth `login_hint` already pre-filled the username * field on page 1; we still tap Continue to advance to page 2, then enter the password @@ -291,7 +366,7 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com return action() } catch (e: Exception) { lastException = e - Thread.sleep(TIMEOUT_MS / 4) + Thread.sleep(SLEEP_TIME_MS) } } throw AssertionError( diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 967c3ac02f..4aff721497 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -222,9 +222,14 @@ abstract class AuthFlowTest { // then generates the OAuth URL and launches the Custom Tab. Wait for that tab to // actually appear rather than sleeping a fixed interval. chromePage.waitForCustomTab() + } else if (pickerShowing) { + // When the picker was showing, changeServerByUrl() tapped the server row which + // triggers an auth-config fetch and then reloads the WebView. Wait for the + // MORE_OPTIONS_BUTTON to confirm the LoginActivity's Compose is idle and the login + // screen is fully in front before returning — this ensures the WebView reload has + // begun and retryWebAction has the full timeout budget to wait for the login form. + loginPage.waitForLoginScreen() } - // For the WebView path there is nothing to wait for: the WebView page-object actions - // retry internally until the reloaded login form is ready. } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt index 1bab3bb710..aeaaaa4233 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt @@ -65,8 +65,10 @@ enum class KnownAppConfig { ECA_JWT_DPOP_RTR, } +private val json = Json { ignoreUnknownKeys = true } + val testConfig: UITestConfig by lazy { - Json.decodeFromString( + json.decodeFromString( string = ResourceReaderHelper.readAssetFile( InstrumentationRegistry.getInstrumentation().targetContext, /* assetFilePath = */ "ui_test_config.json", From 2b5818a689e42c4c388ef929e389d7a5a7ab27df Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 14 Aug 2026 10:25:38 -0700 Subject: [PATCH 07/12] fix(W-23836447): stabilise dpop_jkt key across pool-server /authorize redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addDpopJktIfNeeded regenerated a new key pair on every call. During pool server login the method is called twice (once for the instance /authorize, once after the pool-server redirect), so the auth code's dpop_jkt binding used Key A but the token-exchange proof used Key C → server rejected with invalid_dpop_proof. Fix: reuse pendingCredentialsIdentifier if already set within the same login flow; only generate a new key pair when starting fresh. --- .../src/com/salesforce/androidsdk/auth/OAuth2.java | 1 - .../com/salesforce/androidsdk/ui/LoginViewModel.kt | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index e49bd1c0f7..28fb27c495 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -776,7 +776,6 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce DPoPNonceCache.INSTANCE.store(credentialsIdentifier, tokenHost, responseNonce); } } - // Nonce challenge: server requires a nonce. Retry once with the harvested nonce. if (attachDPoP && isNonceChallenge(response)) { response.close(); diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index f289b7f2e9..ff4a9e4482 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -716,6 +716,11 @@ open class LoginViewModel( * Adds `dpop_jkt` to [params] when DPoP is enabled. * welcome.salesforce.com/discovery is never passed here — discovery resolves a my-domain * server before /authorize is called. + * + * If [pendingCredentialsIdentifier] is already set (meaning dpop_jkt was already committed + * for this login flow, e.g. via a pool-server redirect), the existing key pair is reused so + * that the auth code's dpop_jkt binding and the token-exchange proof use the same key. + * Only generates a new key pair when starting a fresh login flow. */ private fun addDpopJktIfNeeded( server: String, @@ -732,16 +737,12 @@ open class LoginViewModel( return } runCatching { - // Delete any orphaned key from a prior server-picker navigation before generating a new one. - pendingCredentialsIdentifier?.let { - DPoPKeyManager.deleteKeyPair(DPoPKeyManager.aliasForCredentialsIdentifier(it)) - } - val credId = java.util.UUID.randomUUID().toString() + val credId = pendingCredentialsIdentifier + ?: java.util.UUID.randomUUID().toString().also { pendingCredentialsIdentifier = it } val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) params["dpop_jkt"] = thumbprint - pendingCredentialsIdentifier = credId }.onFailure { t -> android.util.Log.w(TAG, "Failed to compute dpop_jkt for /authorize; proceeding without it", t) } From e702d358e208457bfb965cd93c6566b18276d07f Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 14 Aug 2026 15:54:38 -0700 Subject: [PATCH 08/12] docs+test(W-23864247): skip pool server DPoP test; update AuthFlowTester README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Ignore testECAJwtDPoP_ViaLoginPoolServer — server-side bug W-23864247 causes invalid_dpop_proof on the authorization-code token exchange at login.test1.pc-rnd. Re-enable when server fix is confirmed. - Add missing tests to DPoPLoginTests table in README (pool server, mixed multi-user) --- native/NativeSampleApps/AuthFlowTester/README.md | 2 ++ .../com/salesforce/samples/authflowtester/DPoPLoginTests.kt | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/native/NativeSampleApps/AuthFlowTester/README.md b/native/NativeSampleApps/AuthFlowTester/README.md index 24b1c8113c..898c24050f 100644 --- a/native/NativeSampleApps/AuthFlowTester/README.md +++ b/native/NativeSampleApps/AuthFlowTester/README.md @@ -60,9 +60,11 @@ All DPoP tests live here — basic login, RTR, multi-user, migration, and restar | `testECAJwtDPoPRtr_Hybrid` | ECA JWT DPoP RTR | Yes | DPoP + refresh token rotation | | `testECAJwtDPoPRtr_NoHybrid` | ECA JWT DPoP RTR | No | DPoP + refresh token rotation | | `testECAJwtDPoP_MultiUser_UniqueTokens` | ECA JWT DPoP | — | Two users; unique tokens; independent revoke+refresh per user | +| `testECAJwtDPoP_And_NonDPoP_MultiUser_FlagOff_IndependentProofs` | ECA JWT DPoP + ECA JWT | — | DPoP and non-DPoP users coexist; toggling DPoP off for second user does not affect first | | `testMigrate_ECAJwtDPoP_AddMoreScopes` | ECA JWT DPoP | — | Scope upgrade; DPoP binding preserved | | `testMigrate_ECAJwtDPoP_To_ECAJwtDPoPRtr` | ECA JWT DPoP → ECA JWT DPoP RTR | — | Migrate from DPoP to DPoP+RTR | | `testECAJwtDPoP_WithRestart` | ECA JWT DPoP | — | DPoP EC key pair survives process restart (AndroidKeyStore) | +| `testECAJwtDPoP_ViaLoginPoolServer` | ECA JWT DPoP | — | `@Ignore` (W-23864247 — pool login server rejects valid `dpop_jkt` token exchange) | | `testLoginForAdmin_DPoP` | ECA JWT DPoP | — | Login for Admins hand-off to Custom Tab works with DPoP | #### RTRLoginTests diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt index 3f1a724a9f..ad87d53215 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt @@ -227,6 +227,12 @@ class DPoPLoginTests : AuthFlowTest() { // Login via the pool server (login.test1.pc-rnd.salesforce.com) with DPoP enabled // and verify dpop_jkt was accepted and DPoP binding holds after a revoke+refresh. + // + // Skipped: server-side bug W-23864247 — the pool login server returns + // invalid_dpop_proof on the authorization-code token exchange even though the + // DPoP proof is cryptographically valid and the JWK thumbprint exactly matches + // the dpop_jkt sent in /authorize. Re-enable when the server fix is confirmed. + @Ignore("W-23864247: pool login server rejects valid dpop_jkt token exchange") @Test fun testECAJwtDPoP_ViaLoginPoolServer() { loginAndValidate( From 98f93999d918e64f98194bf26e9d76e3033b5c8b Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 14 Aug 2026 18:16:29 -0700 Subject: [PATCH 09/12] fix(test): fix DPoPLoginTests failures in AuthFlowTester MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. tapLoginForAdminsMenuItem: dismiss server picker before tapping overflow menu — backOutToLoginActivity leaves the non-dismissable login picker showing; the MORE_OPTIONS_BUTTON is hidden while the picker is open so the click failed with 'node not found'. 2. testECAJwtDPoP_And_NonDPoP_MultiUser_FlagOff_IndependentProofs: pass isJwt=true to switchToUserAndValidateUser and assertRevokeAndRefreshWorks for both users — ECA_JWT and ECA_JWT_DPOP both issue JWT tokens so the user agent contains JT, not OT; omitting isJwt caused 'Expected OT flag'. --- .../androidsdk/auth/LoginViewModelTest.kt | 48 +++++++++++++++++++ .../samples/authflowtester/DPoPLoginTests.kt | 10 ++-- .../pageObjects/LoginPageObject.kt | 25 ++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) 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 8a2131417e..0eb1b77bc8 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -515,6 +515,54 @@ class LoginViewModelTest { } } + /** + * Regression guard for W-23836447: pool server login calls generateAuthorizationUrl multiple + * times (pool → my-domain redirect). The dpop_jkt must remain stable across all calls so + * that the auth code's dpop_jkt binding and the subsequent token-exchange DPoP proof use the + * same key. + */ + @Test + fun test_givenDPoPEnabled_whenGenerateAuthorizationUrlCalledTwice_thenDpopJktIsStable() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns true + + // First call — simulates pool server issuing the initial /authorize redirect. + viewModel.generateAuthorizationUrl("https://login.salesforce.com", sdkManagerMock) + val firstUrl = viewModel.loginUrl.value ?: "" + val firstThumbprint = firstUrl.toUri().getQueryParameter("dpop_jkt") ?: "" + val firstCredId = viewModel.pendingCredentialsIdentifier + + assert(firstThumbprint.isNotEmpty()) { + "Expected dpop_jkt after first generateAuthorizationUrl call, got empty" + } + assertNotNull("pendingCredentialsIdentifier must be set after first call", firstCredId) + + // Second call — simulates the pool server redirecting to my-domain /authorize. + viewModel.generateAuthorizationUrl("https://myorg.my.salesforce.com", sdkManagerMock) + val secondUrl = viewModel.loginUrl.value ?: "" + val secondThumbprint = secondUrl.toUri().getQueryParameter("dpop_jkt") ?: "" + val secondCredId = viewModel.pendingCredentialsIdentifier + + assert(secondThumbprint.isNotEmpty()) { + "Expected dpop_jkt after second generateAuthorizationUrl call, got empty" + } + assertEquals( + "dpop_jkt must be stable across pool-server redirects (same key must be reused)", + firstThumbprint, + secondThumbprint, + ) + assertEquals( + "pendingCredentialsIdentifier must be the same across pool-server redirects", + firstCredId, + secondCredId, + ) + } + // endregion // region frontDoorBridgeUrl Tests diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt index ad87d53215..3e45b82cb8 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt @@ -143,14 +143,16 @@ class DPoPLoginTests : AuthFlowTest() { // Switch to user A (DPoP-bound). Refresh + REST GET must attach a DPoP proof — // gated by credential state, not the global flag. - switchToUserAndValidateUser(user, isDpop = true) + // ECA_JWT_DPOP issues JWT tokens, so isJwt=true is required to expect JT in the UA. + switchToUserAndValidateUser(user, isDpop = true, isJwt = true) app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = ScopeSelection.EMPTY) - assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isMultiUser = true) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isMultiUser = true, isJwt = true) // Switch to user B (Bearer). Refresh + REST GET must NOT attach DPoP anywhere. - switchToUserAndValidateUser(otherUser, isDpop = false) + // ECA_JWT issues JWT tokens, so isJwt=true is required to expect JT (not OT) in the UA. + switchToUserAndValidateUser(otherUser, isDpop = false, isJwt = true) app.validateOAuthValues(knownAppConfig = ECA_JWT, scopeSelection = ScopeSelection.EMPTY) - assertRevokeAndRefreshWorks(isRtr = false, isDpop = false, isMultiUser = true) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = false, isMultiUser = true, isJwt = true) } // endregion diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index 679bd50616..3ec9ea6b12 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -46,6 +46,7 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.webClick import androidx.test.espresso.web.webdriver.DriverAtoms.webKeys import androidx.test.espresso.web.webdriver.Locator import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.ui.components.LoginViewTestTags import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig @@ -273,8 +274,32 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com * Opens the top bar overflow menu and taps the "Login for Admins" item. * The SDK then launches the OAuth authorize URL in a Chrome Custom Tab while * the in-app WebView remains loaded underneath. + * + * If the login server picker is showing (e.g. because [ChromeCustomTabPageObject.backOutToLoginActivity] + * left it up after the tab closed), dismiss it first by selecting the current server, so the top + * app bar becomes reachable. This matches the real user gesture: you can't tap the overflow menu + * while the picker modal is covering it. */ fun tapLoginForAdminsMenuItem() { + // Dismiss the server picker if it is covering the top app bar. + val pickerShowing = composeTestRule + .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes() + .isNotEmpty() + if (pickerShowing) { + // Select the currently active login host to dismiss the picker without changing servers. + val currentUrl = SalesforceSDKManager.getInstance() + .loginServerManager.selectedLoginServer?.url + if (currentUrl != null) { + composeTestRule.onAllNodesWithText(currentUrl, substring = true) + .filterToOne(hasClickAction()) + .performClick() + composeTestRule.waitForIdle() + // Wait for the picker to close and the top bar to come back. + waitForLoginScreen() + } + } + // Tap "More Options" three-dot menu (Compose IconButton) composeTestRule.onNodeWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) .performClick() From ecfa2a5dc95371f61cbda9cd547e092ed2099894 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 14 Aug 2026 18:17:56 -0700 Subject: [PATCH 10/12] fix(test): use WEBVIEW_ACTION_TIMEOUT_MS for Chrome Custom Tab field discovery setUsername / setPassword / tapLogin were wasting TIMEOUT_MS (15-20 s) on a UiSelector.resourceId lookup that always fails inside Chrome (HTML element IDs are not Android resource IDs), then only giving the same TIMEOUT_MS to the className fallback that actually finds the field. For a cold Chrome profile (FRE dismissed but Salesforce page still loading), the combined two-timeout wait (30-40 s) could expire before the login form appeared, causing intermittent "Username field not found in Custom Tab". Fix: quick-check resourceId (500 ms), then fall through to className with the extended WEBVIEW_ACTION_TIMEOUT_MS (45 s local / 60 s FTL). --- .../pageObjects/ChromeCustomTabPageObject.kt | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt index b026a82f58..141bf32050 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt @@ -114,43 +114,44 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje } override fun setUsername(name: String) { - var usernameField = device.findObject(UiSelector().resourceId(USERNAME_ID)) - if (!usernameField.waitForExists(TIMEOUT_MS)) { - usernameField = device.findObject( - UiSelector().className("android.widget.EditText").instance(0) - ) - if (!usernameField.waitForExists(TIMEOUT_MS)) { - throw AssertionError("Username field not found in Custom Tab") - } - } + // UiSelector.resourceId("username") matches Android View resource IDs, not HTML element + // IDs inside Chrome — the quick check is a low-cost probe before the full wait. + val usernameField = device.findObject(UiSelector().resourceId(USERNAME_ID)) + .takeIf { it.waitForExists(QUICK_CHECK_TIMEOUT_MS) } + ?: device.findObject(UiSelector().className("android.widget.EditText").instance(0)) + .also { + // Use the extended WebView timeout: the Salesforce login page can take + // 20–30 s to render the first input field after the tab toolbar appears. + if (!it.waitForExists(WEBVIEW_ACTION_TIMEOUT_MS)) { + throw AssertionError("Username field not found in Custom Tab") + } + } usernameField.click() usernameField.setText(name) } override fun setPassword(password: String) { - var passwordField = device.findObject(UiSelector().resourceId(PASSWORD_ID)) - if (!passwordField.waitForExists(TIMEOUT_MS)) { - passwordField = device.findObject( - UiSelector().className("android.widget.EditText").instance(0) - ) - if (!passwordField.waitForExists(TIMEOUT_MS)) { - throw AssertionError("Password field not found in Custom Tab") - } - } + val passwordField = device.findObject(UiSelector().resourceId(PASSWORD_ID)) + .takeIf { it.waitForExists(QUICK_CHECK_TIMEOUT_MS) } + ?: device.findObject(UiSelector().className("android.widget.EditText").instance(0)) + .also { + if (!it.waitForExists(WEBVIEW_ACTION_TIMEOUT_MS)) { + throw AssertionError("Password field not found in Custom Tab") + } + } passwordField.click() passwordField.setText(password) } override fun tapLogin() { - var loginButton = device.findObject(UiSelector().resourceId(LOGIN_BUTTON_ID)) - if (!loginButton.waitForExists(TIMEOUT_MS)) { - loginButton = device.findObject( - UiSelector().className("android.widget.Button").textContains("Log In") - ) - if (!loginButton.waitForExists(TIMEOUT_MS)) { - throw AssertionError("Log In button not found in Custom Tab") - } - } + val loginButton = device.findObject(UiSelector().resourceId(LOGIN_BUTTON_ID)) + .takeIf { it.waitForExists(QUICK_CHECK_TIMEOUT_MS) } + ?: device.findObject(UiSelector().className("android.widget.Button").textContains("Log In")) + .also { + if (!it.waitForExists(TIMEOUT_MS)) { + throw AssertionError("Log In button not found in Custom Tab") + } + } loginButton.click() } From c6abcff744cca29f3f3b0fb54228250e09588fec Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 14 Aug 2026 19:38:17 -0700 Subject: [PATCH 11/12] fix(test): fix testLoginForAdmin_DPoP and address PR #2988 review feedback - ChromeCustomTabPageObject: replace backOutToLoginActivity() with waitForLoginScreen() in tapLoginForAdminsMenuItem override, and set isBrowserLoginEnabled=false before tapping the server row so reloadWebView() loads the in-app WebView instead of relaunching a Custom Tab. This fixes the MORE_OPTIONS_BUTTON not found error after W-23731759 made the login picker non-dismissable. - LoginViewModel: add defensive Welcome Discovery guard to addDpopJktIfNeeded for belt-and-suspenders symmetry with the iOS twin (optional suggestion from review, non-blocking). - ECALoginTests: @Ignore testECAJwt_ViaLoginPoolServer until loginPoolHost is provisioned in the CI ui_test_config.json (blocking issue from PR review). --- .../androidsdk/ui/LoginViewModel.kt | 6 ++- .../samples/authflowtester/ECALoginTests.kt | 5 +++ .../pageObjects/ChromeCustomTabPageObject.kt | 45 +++++++++++++++++++ .../pageObjects/LoginPageObject.kt | 27 +---------- .../testUtility/AuthFlowTest.kt | 12 +++-- 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index ff4a9e4482..dc2fa3e15d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -727,7 +727,11 @@ open class LoginViewModel( sdkManager: SalesforceSDKManager, params: MutableMap, ) { - if (!sdkManager.useDPoP) { + // Welcome Discovery is a pre-authentication host, not a resource server — never attach + // dpop_jkt there. Belt-and-suspenders guard: generateAuthorizationUrl is not called for + // the discovery URL today (reloadWebView short-circuits it), but this prevents a stale + // thumbprint from leaking if the call graph changes in the future. + if (!sdkManager.useDPoP || LoginServerManager.WELCOME_LOGIN_URL == server) { // Clear any stale dpop_jkt and its key from a previous server-picker entry. params.remove("dpop_jkt") pendingCredentialsIdentifier?.let { diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt index 0516bb213e..e0891a5464 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt @@ -33,6 +33,7 @@ import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -87,6 +88,10 @@ fun testECAJwt_AllScopes() { // region ECA Pool Server Tests // Login via the pool server without DPoP and verify the session is valid. + // + // Skipped: loginPoolHost not yet provisioned in CI ui_test_config.json. + // Re-enable once the key is added and the CI environment can reach the pool server. + @Ignore("loginPoolHost not provisioned in CI config — add the key and re-enable") @Test fun testECAJwt_ViaLoginPoolServer() { loginAndValidate(knownAppConfig = ECA_JWT, useLoginPoolHost = true) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt index 141bf32050..f487c146a3 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt @@ -27,11 +27,16 @@ package com.salesforce.samples.authflowtester.pageObjects import androidx.compose.ui.test.ComposeTimeoutException +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.performClick import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector +import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.ui.components.LoginViewTestTags import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH @@ -82,6 +87,46 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(ADVANCED_AUTH) } + /** + * Opens the top bar overflow menu and taps the "Login for Admins" item. + * + * Overrides [LoginPageObject.tapLoginForAdminsMenuItem] to handle the case where the login + * server picker is showing after [backOutToLoginActivity] closed the forced-advanced-auth tab. + * The picker is non-dismissable except by selecting a server. Selecting the current server + * calls [LoginViewModel.reloadWebView], which checks [SalesforceSDKManager.isBrowserLoginEnabled] + * to decide whether to launch a Custom Tab or load the in-app WebView. We ensure the flag is + * false before the tap so the reload uses the WebView path and the picker is dismissed without + * launching another tab — [waitForLoginScreen] then confirms the top app bar is reachable. + */ + override fun tapLoginForAdminsMenuItem() { + val pickerShowing = composeTestRule + .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes() + .isNotEmpty() + if (pickerShowing) { + val currentUrl = SalesforceSDKManager.getInstance() + .loginServerManager.selectedLoginServer?.url + if (currentUrl != null) { + // Belt-and-suspenders: ensure browser-login is off before the row tap so that the + // reloadWebView() call inside onNewLoginServerSelected loads the WebView rather than + // relaunching a Custom Tab (which would show the picker again when closed). + SalesforceSDKManager.getInstance().run { + forceAdvancedAuthentication = false + isBrowserLoginEnabled = false + } + composeTestRule + .onAllNodesWithText(currentUrl, substring = true) + .filterToOne(hasClickAction()) + .performClick() + composeTestRule.waitForIdle() + // Picker is now dismissed (showServerPicker.value = false). Wait for the + // MORE_OPTIONS_BUTTON to appear in the top app bar before calling super. + waitForLoginScreen() + } + } + super.tapLoginForAdminsMenuItem() + } + /** * Surfaces the LoginActivity (or the server picker) by closing the Custom Tab that forced * advanced auth auto-launches over it. The login picker is non-dismissable, so callers that diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index 3ec9ea6b12..d757a56e4e 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -46,7 +46,6 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.webClick import androidx.test.espresso.web.webdriver.DriverAtoms.webKeys import androidx.test.espresso.web.webdriver.Locator import com.salesforce.androidsdk.R -import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.ui.components.LoginViewTestTags import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig @@ -274,32 +273,8 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com * Opens the top bar overflow menu and taps the "Login for Admins" item. * The SDK then launches the OAuth authorize URL in a Chrome Custom Tab while * the in-app WebView remains loaded underneath. - * - * If the login server picker is showing (e.g. because [ChromeCustomTabPageObject.backOutToLoginActivity] - * left it up after the tab closed), dismiss it first by selecting the current server, so the top - * app bar becomes reachable. This matches the real user gesture: you can't tap the overflow menu - * while the picker modal is covering it. */ - fun tapLoginForAdminsMenuItem() { - // Dismiss the server picker if it is covering the top app bar. - val pickerShowing = composeTestRule - .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) - .fetchSemanticsNodes() - .isNotEmpty() - if (pickerShowing) { - // Select the currently active login host to dismiss the picker without changing servers. - val currentUrl = SalesforceSDKManager.getInstance() - .loginServerManager.selectedLoginServer?.url - if (currentUrl != null) { - composeTestRule.onAllNodesWithText(currentUrl, substring = true) - .filterToOne(hasClickAction()) - .performClick() - composeTestRule.waitForIdle() - // Wait for the picker to close and the top bar to come back. - waitForLoginScreen() - } - } - + open fun tapLoginForAdminsMenuItem() { // Tap "More Options" three-dot menu (Compose IconButton) composeTestRule.onNodeWithTag(LoginViewTestTags.MORE_OPTIONS_BUTTON) .performClick() diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 4aff721497..6c1af675bc 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -540,9 +540,15 @@ abstract class AuthFlowTest { } loginOptions.setOverrideBootConfig(knownAppConfig, scopeSelection = EMPTY) - // Dismissing Login Options re-launches the Custom Tab on the forced-advanced-auth path; - // back out again to reach the overflow menu (no-op on the WebView path), then launch the - // dedicated admin custom tab. + // Dismissing Login Options re-launches the Custom Tab on the forced-advanced-auth path. + // After backing out of that tab, the non-dismissable login picker is shown (W-23731759). + // To reach the overflow menu, disable forced advanced authentication before closing the tab + // so that ChromeCustomTabPageObject.tapLoginForAdminsMenuItem can dismiss the picker by + // re-selecting the current server (which triggers reloadWebView with isBrowserLoginEnabled=false, + // loading the in-app WebView instead of yet another Custom Tab). + if (useWebServerFlow) { + setForcedAdvancedAuthEnabled(false) + } topBarPage.backOutToLoginActivity() topBarPage.tapLoginForAdminsMenuItem() From c581e7592dc2be076999bbdf9946a28084ead351 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Sat, 15 Aug 2026 07:36:51 -0700 Subject: [PATCH 12/12] fix(test): fix testChangeDynamicConfigWithoutLogin_existingUserUnaffected (W-23731759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login-server picker became a non-dismissable ModalBottomSheet in PR #2983: its back handler rejects the Hidden state so device back presses are swallowed while the picker is showing. After backOutToLoginActivity() closes the forced- advanced-auth Custom Tab, clearWebView(showServerPicker=true) shows the picker; navigateBackToApp's 4 back presses all land in the sheet and LoginActivity never finishes. Fix: add LoginPageObject.exitServerPickerIfShowing() which taps the picker header's PICKER_LOGIN_BACK_BUTTON — the production-correct way to exit login when an authenticated user exists (invokes LoginActivity.handleBackBehavior() → finish()). Call it in testChangeDynamicConfigWithoutLogin_existingUserUnaffected after backOutToLoginActivity() and before navigateBackToApp(). Verified: testChangeDynamicConfigWithoutLogin_existingUserUnaffected passes. MultiUserLoginTests: 17/17 passing, 0 failures. --- .../authflowtester/NegativeLoginTests.kt | 19 +++++++------ .../pageObjects/LoginPageObject.kt | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NegativeLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NegativeLoginTests.kt index 241ba69255..06930504a0 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NegativeLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NegativeLoginTests.kt @@ -102,10 +102,13 @@ class NegativeLoginTests : AuthFlowTest() { loginPage.openLoginOptions() loginOptions.setOverrideBootConfig(ECA_OPAQUE, EMPTY) - // Saving Login Options re-launches the Custom Tab. Back out of it (and dismiss the - // resulting server picker) so navigateBackToApp only has to walk the remaining - // LoginActivity -> AccountSwitcher -> AuthFlowTester stack. + // Saving Login Options re-launches the Custom Tab. Back out of it, then exit the flow via + // the picker's login-exit back button: since W-23731759 the login-server picker is a + // non-dismissable modal sheet that swallows device back presses, so it must be dismissed + // through its own back button (which finishes LoginActivity) before navigateBackToApp can + // walk the remaining AccountSwitcher -> AuthFlowTester stack. loginPage.backOutToLoginActivity() + loginPage.exitServerPickerIfShowing() navigateBackToApp() // The existing user must remain the only authenticated account. @@ -179,11 +182,11 @@ class NegativeLoginTests : AuthFlowTest() { private const val INVALID_SCOPE = "invalid_scope_for_negative_tests" // Maximum number of back-presses to walk from a saved-but-unused - // dynamic config back to the AuthFlowTester main screen. - // LoginOptions has been dismissed by Save, so worst-case stack is - // LoginActivity -> AccountSwitcher -> AuthFlowTester (2 presses); - // an extra press accommodates devices that are slow to dismiss - // dialogs or transitions. + // dynamic config back to the AuthFlowTester main screen. The login + // picker has already been exited via its back button (which finishes + // LoginActivity), so worst-case stack is AccountSwitcher -> + // AuthFlowTester (2 presses); an extra press accommodates devices that + // are slow to dismiss dialogs or transitions. private const val BACK_PRESS_LIMIT = 4 private const val PER_BACK_PRESS_TIMEOUT_MS = 3_000L private const val POLL_INTERVAL_MS = 250L diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index d757a56e4e..6e984b0057 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -92,6 +92,33 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(knownLoginHostConfig) } + /** + * Exits the login flow when the non-dismissable login-server picker (W-23731759) is in front. + * + * Since the picker became non-dismissable its [androidx.compose.material3.ModalBottomSheet] + * swallows device back presses (the sheet's back handler tries to hide it, which + * `confirmValueChange` rejects), so a caller cannot walk back to the app with `pressBack()` + * while the picker is up. Instead we tap the picker header's login-exit back button, which + * invokes `LoginActivity.handleBackBehavior()` and finishes the activity — the same effect a + * back press had before the picker was made modal. That button is only rendered when there is + * an authenticated user (`LoginViewModel.shouldShowBackButton`), which is the case for callers + * that reached the picker via "Add New Account". + * + * @return true if the picker was showing and its back button was tapped; false otherwise. + */ + fun exitServerPickerIfShowing(): Boolean { + val pickerShowing = composeTestRule + .onAllNodesWithTag(LoginViewTestTags.SERVER_PICKER) + .fetchSemanticsNodes() + .isNotEmpty() + if (!pickerShowing) return false + + composeTestRule.onNodeWithTag(LoginViewTestTags.PICKER_LOGIN_BACK_BUTTON) + .performClick() + composeTestRule.waitForIdle() + return true + } + /** * Returns true when the LoginActivity top bar is currently in front * (detected via the SDK's locale-invariant "More Options" test tag). Used by