diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml index 37a7dc4f5a..1c6ad75ff3 100644 --- a/libs/SalesforceSDK/res/values/sf__strings.xml +++ b/libs/SalesforceSDK/res/values/sf__strings.xml @@ -127,4 +127,16 @@ Redirect URI Field Scopes Field Save Button + Save and Login + + + Simulate Discovery Result + Toggle Simulate Discovery Result + My Domain Login Host + User Name + Save Simulated Result + Discovery Login Host Field + Discovery Username Field + Save Simulated Result Button diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 300edda251..4879c60103 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -475,6 +475,37 @@ open class SalesforceSDKManager protected constructor( // Used to ensure the webview is reloaded when Dev Menu Login Options are changed. internal var loginDevMenuReload = false + /** + * When set, the next launch of [com.salesforce.androidsdk.ui.LoginActivity] against + * [com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL] is short-circuited + * to use these values instead of running the real Welcome Discovery WebView flow. This + * mirrors the iOS `simulatedDomainDiscoveryResult` hook and is the seam used by automated + * UI tests to inject a login hint and My Domain. + * + * The setter is a no-op in release builds (only honored when [isDebugBuild] is true) so + * release apps cannot be coerced into bypassing the real discovery flow. + */ + internal var simulatedDiscoveryResult: LoginActivity.Companion.SimulatedDiscoveryResult? = null + set(value) { + if (isDebugBuild) field = value + } + + /** + * When true (and [isDebugBuild] is also true), debug-only UI test affordances such as the + * Welcome Discovery simulation editor in + * [com.salesforce.androidsdk.ui.LoginOptionsActivity] are visible. Mirrors iOS' check for + * the `IS_UI_TESTING` launch argument in `LoginOptionsViewController.swift`. + * + * Set by a sample app's launcher Activity from an Intent extra when the activity is + * launched by the UI test runner. The setter is a no-op in release builds so manual + * launches and release-build apps never expose the affordances. + */ + @VisibleForTesting + var isUiTesting: Boolean = false + set(value) { + if (isDebugBuild) field = value + } + /** * The regular expression pattern used to detect "Use Custom Domain" input * from login web view. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 882f4e6c5b..65a8b0842d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -974,6 +974,44 @@ open class LoginActivity : FragmentActivity() { } } + /** + * If [SalesforceSDKManager.simulatedDiscoveryResult] has been armed by the Login Options + * Discovery Result Editor, consume it and dispatch the same hint+host intent the real + * `sfdc://discocallback` callback URL handler would dispatch. This is the test seam + * mirroring iOS' `simulatedDomainDiscoveryResult` hook. + * + * The WebView's current URL is set to ABOUT_BLANK before dispatching so that the + * subsequent OAuth-URL reload triggered by [applySalesforceWelcomeLoginHintAndHost]'s + * `addCustomLoginServer` is not suppressed by [LoginViewModel.LoginUrlSource]'s + * same-host short-circuit when the simulated host happens to equal the previously + * selected server. + * + * @return Boolean true when the simulated result was applied and the welcome.salesforce.com + * discovery WebView load should be skipped, false otherwise. + */ + @VisibleForTesting + internal fun applySimulatedDiscoveryResultIfApplicable(): Boolean { + val sdkManager = SalesforceSDKManager.getInstance() + // Defense-in-depth: refuse to consume a simulated result outside debug builds, mirroring + // iOS' `#if DEBUG` wrap on the same hook. + if (!sdkManager.isDebugBuild) return false + val simulated = sdkManager.simulatedDiscoveryResult ?: return false + + // Consume the simulated result so subsequent welcome-discovery selections run the real flow + // unless re-armed via Login Options. + sdkManager.simulatedDiscoveryResult = null + + // Reset the WebView so the post-intent reload isn't dropped by the same-host short-circuit. + viewModel.loginUrl.value = ABOUT_BLANK + + startDefaultLoginWithHintAndHost( + context = this, + loginHint = simulated.loginHint, + loginHost = simulated.loginHost, + ) + return true + } + /** * If the intent has the Salesforce Welcome login hint and host, applies * those for use in the generation of the OAuth URL. This is used by @@ -1044,16 +1082,23 @@ open class LoginActivity : FragmentActivity() { // If the pending login server is a change to a new Salesforce Welcome Discovery URL and host. if (isSalesforceWelcomeDiscoveryUrlPath(pendingLoginServerUri)) { - // Navigate to Salesforce Welcome Discovery. - startActivity( - Intent( - this, - SalesforceSDKManager.getInstance().webViewLoginActivityClass - ).apply { - data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri) - flags = FLAG_ACTIVITY_SINGLE_TOP - }) - true + // If a simulated discovery result has been armed via Login Options, bypass the real + // welcome.salesforce.com discovery WebView and route directly to default login with + // the injected hint and host. Mirrors iOS' simulatedDomainDiscoveryResult hook. + if (applySimulatedDiscoveryResultIfApplicable()) { + true + } else { + // Navigate to Salesforce Welcome Discovery. + startActivity( + Intent( + this, + SalesforceSDKManager.getInstance().webViewLoginActivityClass + ).apply { + data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri) + flags = FLAG_ACTIVITY_SINGLE_TOP + }) + true + } } // If the pending login server isn't a Salesforce Welcome Discovery URL but the previous was... @@ -1485,6 +1530,17 @@ open class LoginActivity : FragmentActivity() { @VisibleForTesting const val SALESFORCE_WELCOME_DISCOVERY_URL_PATH = "/discovery" + /** + * The result of a simulated Welcome Discovery flow. Holds the values that the real + * `sfdc://discocallback?login_hint=&my_domain=` URL would have provided. Set on + * [SalesforceSDKManager.simulatedDiscoveryResult] from the Login Options screen to + * bypass the email round-trip during automated tests. + */ + data class SimulatedDiscoveryResult( + val loginHint: String, + val loginHost: String, + ) + /** * Determines if the provided URL has the Salesforce Welcome Discovery * path. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index 7d4d1e23f0..5028544dfa 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -66,6 +66,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.annotation.VisibleForTesting import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -82,10 +83,11 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.salesforce.androidsdk.R -import com.salesforce.androidsdk.R.string.sf__server_url_save +import com.salesforce.androidsdk.R.string.sf__login_options_save_and_login import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.OAuthConfig +import com.salesforce.androidsdk.ui.LoginActivity.Companion.SimulatedDiscoveryResult import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport @@ -274,7 +276,7 @@ fun BootConfigView(config: OAuthConfig? = null) { }, ) { Text( - text = stringResource(sf__server_url_save), + text = stringResource(sf__login_options_save_and_login), fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium, color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer, ) @@ -379,9 +381,133 @@ fun LoginOptionsScreen( if (useDynamicConfig) { BootConfigView(overrideConfig) } + + // Welcome Discovery simulation editor: visible only when the launcher Activity flagged + // the process as UI-testing (and only in debug builds, enforced by the setter). + // Mirrors iOS' IS_UI_TESTING gate in LoginOptionsViewController.swift. + val sdkManager = SalesforceSDKManager.getInstance() + if (sdkManager.isDebugBuild && sdkManager.isUiTesting) { + HorizontalDivider() + + var simulateDiscovery by remember { + mutableStateOf(sdkManager.simulatedDiscoveryResult != null) + } + Row( + modifier = Modifier.fillMaxWidth().padding(PADDING_SIZE.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.sf__login_options_discovery_simulate_title), + modifier = Modifier.height(50.dp).wrapContentHeight(align = Alignment.CenterVertically), + ) + val toggleDesc = stringResource(R.string.sf__login_options_discovery_toggle_content_description) + Switch( + checked = simulateDiscovery, + onCheckedChange = { + simulateDiscovery = it + if (!simulateDiscovery) { + sdkManager.simulatedDiscoveryResult = null + } + }, + modifier = Modifier.clearAndSetSemantics { + contentDescription = toggleDesc + toggleableState = ToggleableState(simulateDiscovery) + role = Role.Switch + } + ) + } + + if (simulateDiscovery) { + DiscoveryResultEditor() + } + } } } +/** + * Editor body for simulating a Welcome Discovery result (login host + username). Mirrors + * iOS `DiscoveryResultEditor.swift`. Tapping Save arms + * [SalesforceSDKManager.simulatedDiscoveryResult]; the next Welcome Discovery launch will + * inject these values instead of running the real WebView discovery flow. Hosted under a + * Switch in [LoginOptionsScreen] (parallels the Override Boot Config section). + */ +@Composable +fun DiscoveryResultEditor( + sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(), +) { + val initial = sdkManager.simulatedDiscoveryResult + var loginHost by remember { mutableStateOf(initial?.loginHost ?: "") } + var username by remember { mutableStateOf(initial?.loginHint ?: "") } + + val hostLabel = stringResource(R.string.sf__login_options_discovery_login_host_label) + val userLabel = stringResource(R.string.sf__login_options_discovery_username_label) + val saveText = stringResource(R.string.sf__login_options_discovery_save_button) + val hostFieldDesc = stringResource(R.string.sf__login_options_discovery_login_host_field_content_description) + val userFieldDesc = stringResource(R.string.sf__login_options_discovery_username_field_content_description) + val saveButtonDesc = stringResource(R.string.sf__login_options_discovery_save_button_content_description) + + Column(modifier = Modifier.padding(PADDING_SIZE.dp)) { + OutlinedTextField( + value = loginHost, + onValueChange = { loginHost = it }, + label = { Text(hostLabel) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = hostFieldDesc }, + ) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(userLabel) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = PADDING_SIZE.dp) + .semantics { contentDescription = userFieldDesc }, + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = PADDING_SIZE.dp) + .semantics { contentDescription = saveButtonDesc }, + shape = RoundedCornerShape(CORNER_RADIUS.dp), + contentPadding = PaddingValues(PADDING_SIZE.dp), + colors = ButtonColors( + containerColor = colorScheme.tertiary, + contentColor = colorScheme.tertiary, + disabledContainerColor = colorScheme.surfaceVariant, + disabledContentColor = colorScheme.surfaceVariant, + ), + onClick = { + sdkManager.simulatedDiscoveryResult = applySimulatedDiscoveryResult( + loginHost = loginHost, + username = username, + ) + }, + ) { + Text(text = saveText, color = colorScheme.onPrimary) + } + } +} + +/** + * Builds a [SimulatedDiscoveryResult] from the current editor values, or null when the host + * is empty. Mirrors iOS `applySimulatedResult` (empty host returns nil). Visible for testing. + */ +@VisibleForTesting +internal fun applySimulatedDiscoveryResult( + loginHost: String, + username: String, +): SimulatedDiscoveryResult? { + val trimmedHost = loginHost.trim() + val trimmedUser = username.trim() + return if (trimmedHost.isEmpty()) null + else SimulatedDiscoveryResult(loginHint = trimmedUser, loginHost = trimmedHost) +} + @ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Composable diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index eb024cf7a5..e093d9872a 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -43,7 +43,10 @@ import com.salesforce.androidsdk.ui.LoginActivity.Companion.SALESFORCE_WELCOME_D import com.salesforce.androidsdk.ui.LoginActivity.Companion.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION import com.salesforce.androidsdk.ui.LoginActivity.Companion.SALESFORCE_WELCOME_DISCOVERY_URL_PATH import com.salesforce.androidsdk.ui.LoginActivity.Companion.isSalesforceWelcomeDiscoveryMobileUrl +import com.salesforce.androidsdk.ui.LoginActivity.Companion.SimulatedDiscoveryResult import com.salesforce.androidsdk.ui.LoginActivity.Companion.startDefaultLoginWithHintAndHost +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.config.LoginServerManager import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -325,5 +328,70 @@ class LoginActivityTest { } } + @Test + fun applySimulatedDiscoveryResult_noArmedResult_returnsFalse() { + SalesforceSDKManager.getInstance().simulatedDiscoveryResult = null + val activity = mockk(relaxed = true) + every { activity.applySimulatedDiscoveryResultIfApplicable() } answers { callOriginal() } + + assertFalse(activity.applySimulatedDiscoveryResultIfApplicable()) + } + + @Test + fun applySimulatedDiscoveryResult_armedResult_consumesAndReturnsTrue() { + val sdkManager = SalesforceSDKManager.getInstance() + sdkManager.simulatedDiscoveryResult = SimulatedDiscoveryResult( + loginHint = "user@example.com", + loginHost = "test.my.example.com", + ) + try { + val loginUrl = mockk>(relaxed = true) + val viewModel = mockk(relaxed = true) + every { viewModel.loginUrl } returns loginUrl + val activity = mockk(relaxed = true) + every { activity.viewModel } returns viewModel + every { activity.applySimulatedDiscoveryResultIfApplicable() } answers { callOriginal() } + + assertTrue(activity.applySimulatedDiscoveryResultIfApplicable()) + // Armed result is consumed (cleared) on apply. + org.junit.Assert.assertNull(sdkManager.simulatedDiscoveryResult) + // WebView is reset so the next reload isn't suppressed by same-host short-circuit. + verify(exactly = 1) { loginUrl.value = ABOUT_BLANK } + } finally { + sdkManager.simulatedDiscoveryResult = null + } + } + + @Test + fun switchDefaultOrSalesforceWelcomeDiscoveryLogin_consumesArmedSimulationInsteadOfLoadingDiscoveryWebView() { + val sdkManager = SalesforceSDKManager.getInstance() + sdkManager.simulatedDiscoveryResult = SimulatedDiscoveryResult( + loginHint = "user@example.com", + loginHost = "test.my.example.com", + ) + try { + val loginUrl = mockk>(relaxed = true) + val viewModel = mockk(relaxed = true) + every { viewModel.loginUrl } returns loginUrl + val activity = mockk(relaxed = true) + every { activity.viewModel } returns viewModel + every { activity.applySimulatedDiscoveryResultIfApplicable() } answers { callOriginal() } + every { activity.switchDefaultOrSalesforceWelcomeDiscoveryLogin(any()) } answers { callOriginal() } + + assertTrue( + activity.switchDefaultOrSalesforceWelcomeDiscoveryLogin( + "https://welcome.example.com/discovery".toUri() + ) + ) + // Discovery WebView intent should NOT be dispatched - the simulation hook handled it. + verify(exactly = 0) { + activity.startActivity(match { it.data?.path?.contains("/discovery") == true }) + } + org.junit.Assert.assertNull(sdkManager.simulatedDiscoveryResult) + } finally { + sdkManager.simulatedDiscoveryResult = null + } + } + // endregion } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt index b960dfc249..ca5e967340 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt @@ -111,7 +111,7 @@ class LoginOptionsActivityTest { composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description), ) saveButton = composeTestRule.onNodeWithText( - composeTestRule.activity.getString(R.string.sf__server_url_save), + composeTestRule.activity.getString(R.string.sf__login_options_save_and_login), ) } @@ -410,4 +410,89 @@ class LoginOptionsActivityTest { composeTestRule.activity.finish() assertTrue(SalesforceSDKManager.getInstance().loginDevMenuReload) } + + // region Welcome Discovery — DiscoveryResultEditor + + @Test + fun applySimulatedDiscoveryResult_emptyHost_returnsNull() { + assertNull(applySimulatedDiscoveryResult(loginHost = "", username = "user@example.com")) + assertNull(applySimulatedDiscoveryResult(loginHost = " ", username = "user@example.com")) + } + + @Test + fun applySimulatedDiscoveryResult_validHost_returnsTrimmedResult() { + val result = applySimulatedDiscoveryResult( + loginHost = " test.my.salesforce.com ", + username = " user@example.com ", + ) + assertNotNull(result) + assertEquals("test.my.salesforce.com", result?.loginHost) + assertEquals("user@example.com", result?.loginHint) + } + + @Test + fun applySimulatedDiscoveryResult_validHostEmptyUser_keepsEmptyUser() { + // iOS allows empty user (host-only), so do we. + val result = applySimulatedDiscoveryResult( + loginHost = "test.my.salesforce.com", + username = "", + ) + assertNotNull(result) + assertEquals("test.my.salesforce.com", result?.loginHost) + assertEquals("", result?.loginHint) + } + + @Test + fun discoveryResultEditor_saveButton_armsSdkManagerSimulatedResult() { + // The editor is gated on isUiTesting; flip it on for this test (debug build) + // and recompose so the gated UI is rendered. + SalesforceSDKManager.getInstance().isUiTesting = true + composeTestRule.activity.runOnUiThread { composeTestRule.activity.recreate() } + composeTestRule.waitForIdle() + + val toggle = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.sf__login_options_discovery_toggle_content_description + ), + ) + val saveButton = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.sf__login_options_discovery_save_button_content_description + ), + ) + val hostField = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.sf__login_options_discovery_login_host_field_content_description + ), + ) + val userField = composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.sf__login_options_discovery_username_field_content_description + ), + ) + + assertNull(SalesforceSDKManager.getInstance().simulatedDiscoveryResult) + + toggle.performScrollTo() + toggle.performClick() + composeTestRule.waitForIdle() + + hostField.performScrollTo() + hostField.performTextInput("test.my.salesforce.com") + userField.performTextInput("user@example.com") + saveButton.performScrollTo() + saveButton.performClick() + composeTestRule.waitForIdle() + + val armed = SalesforceSDKManager.getInstance().simulatedDiscoveryResult + assertNotNull(armed) + assertEquals("test.my.salesforce.com", armed?.loginHost) + assertEquals("user@example.com", armed?.loginHint) + + // Cleanup: clear simulation + UI testing flag so they don't leak. + SalesforceSDKManager.getInstance().simulatedDiscoveryResult = null + SalesforceSDKManager.getInstance().isUiTesting = false + } + + // endregion } \ No newline at end of file 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 1eb8f62c95..40ed85c914 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 @@ -204,14 +204,16 @@ class RefreshTokenMigrationTests: AuthFlowTest() { useHybridAuthToken: Boolean, knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig, + useWelcomeDiscovery: Boolean, ) { super.loginAndValidate( - knownAppConfig, - scopeSelection, - useWebServerFlow, + knownAppConfig = knownAppConfig, + scopeSelection = scopeSelection, + useWebServerFlow = useWebServerFlow, useHybridAuthToken = false, - knownLoginHostConfig, + knownLoginHostConfig = knownLoginHostConfig, knownUserConfig = user, + useWelcomeDiscovery = useWelcomeDiscovery, ) } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/WelcomeLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/WelcomeLoginTests.kt new file mode 100644 index 0000000000..0f19414b85 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/WelcomeLoginTests.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.samples.authflowtester + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for the Welcome Discovery login flow. + * + * Welcome Discovery normally requires the user to receive a code by email and + * use a server-allow-listed consumer key. These tests use the SDK's Login + * Options "Discovery Result Editor" to inject a simulated discovery result + * (login hint + My Domain), then drive the same code path the real callback + * URL would have produced. Mirrors iOS `WelcomeLoginTests.swift`. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class WelcomeLoginTests : AuthFlowTest() { + + /** Welcome discovery with regular auth host and static config. */ + @Test + fun testWelcomeDiscovery_RegularAuthLoginHost() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = REGULAR_AUTH, + useWelcomeDiscovery = true, + ) + } + + /** Welcome discovery with advanced auth host and static config. */ + @Test + fun testWelcomeDiscovery_AdvancedAuthLoginHost() { + loginAndValidate( + knownAppConfig = BEACON_OPAQUE, + knownLoginHostConfig = ADVANCED_AUTH, + useWelcomeDiscovery = true, + ) + } +} diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt index 14e5c3cafa..73f4bb584e 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt @@ -128,6 +128,32 @@ class LoginOptionsPageObject(composeTestRule: ComposeTestRule): BasePageObject(c composeTestRule.waitForIdle() } + /** + * Flips the Simulate Discovery Result toggle on, types host + username, taps Save. + * Mirrors iOS `LoginOptionsPageObject.configure(...)`. Tapping Save is what arms + * `SalesforceSDKManager.simulatedDiscoveryResult`; the editor's Save -> manager wiring + * is independently covered by the unit test + * `LoginOptionsActivityTest.discoveryResultEditor_saveButton_armsSdkManagerSimulatedResult`. + */ + fun setSimulatedDiscoveryResult(loginHost: String, username: String) { + toggleIfOff(getString(R.string.sf__login_options_discovery_toggle_content_description)) + + composeTestRule.onNodeWithContentDescription( + getString(R.string.sf__login_options_discovery_login_host_field_content_description) + ).performScrollTo().performTextReplacement(loginHost) + + composeTestRule.onNodeWithContentDescription( + getString(R.string.sf__login_options_discovery_username_field_content_description) + ).performTextReplacement(username) + + closeSoftKeyboard() + + composeTestRule.onNodeWithContentDescription( + getString(R.string.sf__login_options_discovery_save_button_content_description) + ).performScrollTo().performClick() + composeTestRule.waitForIdle() + } + private fun isToggleOn(contentDescription: String): Boolean = composeTestRule.onNodeWithContentDescription(contentDescription) .fetchSemanticsNode() 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 7509170b74..7e92b91101 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 @@ -89,6 +89,19 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com false } + /** + * 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 + * and submit. Mirrors iOS performWelcomeLogin. + */ + open fun welcomeLogin(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + val (_, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) + tapLogin() + setPassword(password) + tapLogin() + AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(knownLoginHostConfig) + } + fun openLoginOptions() { // Tap "More Options" three-dot menu (Compose IconButton) composeTestRule.onNodeWithContentDescription(getString(R.string.sf__more_options)) @@ -148,8 +161,15 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com } fun changeServer(knownLoginHostConfig: KnownLoginHostConfig) { - val url = testConfig.getLoginHost(knownLoginHostConfig).url + changeServerByUrl(testConfig.getLoginHost(knownLoginHostConfig).url) + } + /** + * 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`). + */ + fun changeServerByUrl(url: String) { // Tap "More Options" three-dot menu (Compose IconButton) composeTestRule.onNodeWithContentDescription(getString(R.string.sf__more_options)) .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 632c4eb8ca..36c6ef5909 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 @@ -27,7 +27,9 @@ package com.salesforce.samples.authflowtester.testUtility import android.Manifest +import android.content.Intent import android.os.Build +import androidx.annotation.VisibleForTesting import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.test.espresso.Espresso import androidx.test.ext.junit.rules.ActivityScenarioRule @@ -71,7 +73,12 @@ abstract class AuthFlowTest { val composeTestRule = createEmptyComposeRule() @get:Rule(order = 2) - val activityRule = ActivityScenarioRule(AuthFlowTesterActivity::class.java) + val activityRule = ActivityScenarioRule( + Intent( + InstrumentationRegistry.getInstrumentation().targetContext, + AuthFlowTesterActivity::class.java, + ).putExtra(AuthFlowTesterActivity.EXTRA_IS_UI_TESTING, true) + ) val loginOptions = LoginOptionsPageObject(composeTestRule) val app = AuthFlowTesterPageObject(composeTestRule) @@ -130,6 +137,7 @@ abstract class AuthFlowTest { useHybridAuthToken: Boolean = true, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, knownUserConfig: KnownUserConfig = user, + useWelcomeDiscovery: Boolean = false, ) { val loginPage = when(knownLoginHostConfig) { REGULAR_AUTH -> LoginPageObject(composeTestRule) @@ -138,8 +146,11 @@ abstract class AuthFlowTest { ensureRegularAuthServer() - if (!useWebServerFlow || !useHybridAuthToken || - knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY) { + val needsLoginOptions = !useWebServerFlow || !useHybridAuthToken || + knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY || + useWelcomeDiscovery + + if (needsLoginOptions) { loginPage.openLoginOptions() @@ -151,18 +162,56 @@ abstract class AuthFlowTest { loginOptions.disableHybridAuthToken() } + // Set simulated discovery result first - its Save does NOT dismiss the activity, + // unlike the boot-config Save below which calls activity.finish(). + if (useWelcomeDiscovery) { + val (username, _) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) + val targetHost = testConfig.getLoginHost(knownLoginHostConfig).url + .removePrefix("https://").removePrefix("http://") + loginOptions.setSimulatedDiscoveryResult( + loginHost = targetHost, + username = username, + ) + } + if (knownAppConfig == CA_OPAQUE && scopeSelection == EMPTY) { + // No boot config override needed; nothing to save in that section. Espresso.pressBack() } else { + // setOverrideBootConfig taps Save which calls activity.finish(). loginOptions.setOverrideBootConfig(knownAppConfig, scopeSelection) } } - if (knownLoginHostConfig != REGULAR_AUTH) { - loginPage.changeServer(knownLoginHostConfig) - } + if (useWelcomeDiscovery) { + // Drive the flow through the SDK's server picker via Welcome Discovery URL. + // The SDK's switchDefaultOrSalesforceWelcomeDiscoveryLogin path consumes the + // armed simulatedDiscoveryResult and routes the OAuth authorize URL to the + // simulated host with the simulated login_hint. We then complete login with + // the standard flow (which retypes the username; the pre-fill is exercised + // server-side via the OAuth login_hint parameter). + val webViewLoginPage = LoginPageObject(composeTestRule) + webViewLoginPage.changeServerByUrl(WELCOME_DISCOVERY_URL) + + // The simulated host determines the surface: regular_auth -> in-app WebView, + // advanced_auth -> Chrome Custom Tab. In both cases the OAuth login_hint + // already pre-filled the username; only the password step remains. + val welcomeLoginPage: LoginPageObject = when (knownLoginHostConfig) { + REGULAR_AUTH -> webViewLoginPage + ADVANCED_AUTH -> { + val chrome = ChromeCustomTabPageObject(composeTestRule) + chrome.skipGoogleSignIn() + chrome + } + } + welcomeLoginPage.welcomeLogin(knownLoginHostConfig, knownUserConfig) + } else { + if (knownLoginHostConfig != REGULAR_AUTH) { + loginPage.changeServer(knownLoginHostConfig) + } - loginPage.login(knownLoginHostConfig, knownUserConfig) + loginPage.login(knownLoginHostConfig, knownUserConfig) + } app.waitForAppLoad() app.validateUser(knownLoginHostConfig, knownUserConfig) @@ -170,6 +219,11 @@ abstract class AuthFlowTest { app.validateApiRequest() } + companion object { + @VisibleForTesting + const val WELCOME_DISCOVERY_URL = "https://welcome.salesforce.com/discovery" + } + /** * Exercises the "Login for Admins" flow: starts on the REGULAR_AUTH server (in-app * WebView), opens the overflow menu, taps "Login for Admins" to launch a Chrome diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt index 723945b2f8..38ef8eea53 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt @@ -172,6 +172,11 @@ class AuthFlowTesterActivity : SalesforceActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + // Make debug-only UI test affordances visible when launched by the UI test runner. + // Mirrors iOS' IS_UI_TESTING launch argument check in LoginOptionsViewController.swift. + SalesforceSDKManager.getInstance().isUiTesting = + intent.getBooleanExtra(EXTRA_IS_UI_TESTING, false) + setContent { MaterialTheme(colorScheme = getColorScheme()) { TesterUI() @@ -179,6 +184,11 @@ class AuthFlowTesterActivity : SalesforceActivity() { } } + companion object { + /** Intent extra: when true (in a debug build) shows UI-test-only affordances. */ + const val EXTRA_IS_UI_TESTING = "IS_UI_TESTING" + } + override fun onResume(client: RestClient?) { // Keeping reference to rest client this.client = client diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml index c2bbf6b7ea..e75e382726 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml @@ -2,6 +2,7 @@ +