diff --git a/.github/workflows/reusable-lib-workflow.yaml b/.github/workflows/reusable-lib-workflow.yaml index ba0662a311..bd4e930a9a 100644 --- a/.github/workflows/reusable-lib-workflow.yaml +++ b/.github/workflows/reusable-lib-workflow.yaml @@ -173,7 +173,38 @@ jobs: # Sharded runs produce test_results_merged.xml at top level gsutil cp "${BUCKET_PATH}/*test_results_merged.xml" firebase_results/api_${LEVEL}_test_result.xml else - gsutil cp "${BUCKET_PATH}/*/test_result_1.xml" firebase_results/api_${LEVEL}_test_result.xml + # Pass 1: copy original (non-rerun) results + for RESULT_FILE in $(gsutil ls "${BUCKET_PATH}/*/test_result_1.xml" 2>/dev/null | grep -v "rerun"); do + gsutil cp "${RESULT_FILE}" "firebase_results/api_${LEVEL}_test_result.xml" + done + # Pass 2: merge rerun testcases into originals so check_retries detects flaky tests + for RESULT_FILE in $(gsutil ls "${BUCKET_PATH}/*/test_result_1.xml" 2>/dev/null | grep "rerun"); do + RERUN_TMP="firebase_results/api_${LEVEL}_rerun_tmp.xml" + ORIG_FILE="firebase_results/api_${LEVEL}_test_result.xml" + gsutil cp "${RESULT_FILE}" "${RERUN_TMP}" + python3 - "${ORIG_FILE}" "${RERUN_TMP}" "${ORIG_FILE}" << 'PYEOF' + import sys, xml.etree.ElementTree as ET + orig = ET.parse(sys.argv[1]) + rerun = ET.parse(sys.argv[2]) + def suite(t): + r = t.getroot() + return r if r.tag == 'testsuite' else r.find('testsuite') + os_el, rs_el = suite(orig), suite(rerun) + failed_keys = set() + for tc in os_el.findall('testcase'): + if tc.find('failure') is not None or tc.find('error') is not None: + failed_keys.add(f"{tc.get('name','')}|{tc.get('classname','')}|{tc.get('file','')}") + added = 0 + for tc in rs_el.findall('testcase'): + if f"{tc.get('name','')}|{tc.get('classname','')}|{tc.get('file','')}" in failed_keys: + os_el.append(tc) + added += 1 + os_el.set('tests', str(int(os_el.get('tests','0')) + added)) + with open(sys.argv[3], 'w') as f: + f.write(ET.tostring(orig.getroot(), encoding='unicode')) + PYEOF + rm "${RERUN_TMP}" + done fi # Copy all shard data for code coverage (only needed for one level) @@ -198,6 +229,12 @@ jobs: include_empty_in_summary: false simplified_summary: true report_paths: 'firebase_results/**.xml' + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-${{ inputs.lib }} + path: 'firebase_results/**.xml' - name: Convert Code Coverage if: success() || failure() run: ./gradlew libs:${{ inputs.lib }}:convertCodeCoverage diff --git a/.github/workflows/reusable-ui-workflow.yaml b/.github/workflows/reusable-ui-workflow.yaml index 148fb49e7f..c51ca8764d 100644 --- a/.github/workflows/reusable-ui-workflow.yaml +++ b/.github/workflows/reusable-ui-workflow.yaml @@ -41,11 +41,11 @@ jobs: gradle-version: "8.14.3" add-job-summary: on-failure add-job-summary-as-pr-comment: on-failure - - name: Build for Testing + - name: Build App for Testing if: success() || failure() run: | ./gradlew native:NativeSampleApps:AuthFlowTester:assembleDebug - - name: Build Tests + - name: Build UI Tests run: | ./gradlew native:NativeSampleApps:AuthFlowTester:assembleAndroidTest - uses: 'google-github-actions/auth@v2' @@ -54,84 +54,163 @@ jobs: credentials_json: '${{ secrets.GCLOUD_SERVICE_KEY }}' - uses: 'google-github-actions/setup-gcloud@v2' if: success() || failure() - - name: Run Tests + - name: Run PR Tests continue-on-error: true - if: success() || failure() + if: ${{ inputs.is_pr }} env: # Most used according to https://gs.statcounter.com/android-version-market-share/mobile-tablet/worldwide PR_API_VERSION: "35" + run: | + GCLOUD_RESULTS_DIR=authflowtester-pr-build-${{github.run_number}} + + PR_TESTS="class com.salesforce.samples.authflowtester.BootConfigLoginTests#testCAOpaque_DefaultScopes_WebServerFlow, \ + class com.salesforce.samples.authflowtester.ECALoginTests#testECAOpaque_DefaultScopes, \ + class com.salesforce.samples.authflowtester.ECALoginTests#testECAJwt_AllScopes, \ + class com.salesforce.samples.authflowtester.TokenMigrationTest#testMigrate_ECA_AddMoreScopes, \ + class com.salesforce.samples.authflowtester.MultiUserLoginTests#testSameApp_SameScopes_uniqueTokens" + + gcloud firebase test android run \ + --project mobile-apps-firebase-test \ + --type instrumentation \ + --use-orchestrator \ + --environment-variables clearPackageData=true \ + --app "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/debug/AuthFlowTester-debug.apk" \ + --test "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/androidTest/debug/AuthFlowTester-debug-androidTest.apk" \ + --device model=MediumPhone.arm,version="${PR_API_VERSION}",locale=en,orientation=portrait \ + --directories-to-pull=/sdcard \ + --results-dir="${GCLOUD_RESULTS_DIR}" \ + --results-history-name=AuthFlowTester \ + --no-performance-metrics \ + --test-targets="${PR_TESTS}" \ + --timeout=10m \ + --num-flaky-test-attempts=1 + - name: Run All Single User Tests + continue-on-error: true + if: ${{ ! inputs.is_pr }} + env: FULL_API_RANGE: "28 29 30 31 32 33 34 35 36" - IS_PR: ${{ inputs.is_pr }} run: | - LEVELS_TO_TEST=$FULL_API_RANGE - RETRIES=0 - TEST_TARGETS="" - - if $IS_PR ; then - LEVELS_TO_TEST=$PR_API_VERSION - RETRIES=1 - # Run only a handful of smoke tests. - TEST_TARGETS="--test-targets \"class com.salesforce.samples.authflowtester.LoginTest#testBasicLogin\"" - TEST_TARGETS+=",\"class com.salesforce.samples.authflowtester.TokenMigrationTest#testMigrate_ECA_AddMoreScopes\"" - fi - - mkdir firebase_results - for LEVEL in $LEVELS_TO_TEST - do - GCLOUD_RESULTS_DIR=authflowtester-api-${LEVEL}-build-${{github.run_number}} - - eval gcloud firebase test android run \ - --project mobile-apps-firebase-test \ - --type instrumentation \ - --use-orchestrator \ - --environment-variables clearPackageData=true \ - --app "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/debug/AuthFlowTester-debug.apk" \ - --test "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/androidTest/debug/AuthFlowTester-debug-androidTest.apk" \ - --device model=MediumPhone.arm,version=${LEVEL},locale=en,orientation=portrait \ - --directories-to-pull=/sdcard \ - --results-dir=${GCLOUD_RESULTS_DIR} \ - --results-history-name=AuthFlowTester \ - --timeout=10m --no-performance-metrics \ - $TEST_TARGETS \ - --num-flaky-test-attempts=${RETRIES} || true - done + GCLOUD_RESULTS_DIR=authflowtester-single-user-build-${{github.run_number}} + DEVICE_ARGS=() + for LEVEL in $FULL_API_RANGE; do + DEVICE_ARGS+=(--device "model=MediumPhone.arm,version=${LEVEL},locale=en,orientation=portrait") + done + + gcloud firebase test android run \ + --project mobile-apps-firebase-test \ + --type instrumentation \ + --use-orchestrator \ + --environment-variables clearPackageData=true \ + --app "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/debug/AuthFlowTester-debug.apk" \ + --test "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/androidTest/debug/AuthFlowTester-debug-androidTest.apk" \ + --test-targets "notClass com.salesforce.samples.authflowtester.MultiUserLoginTests" \ + "${DEVICE_ARGS[@]}" \ + --directories-to-pull=/sdcard \ + --results-dir="${GCLOUD_RESULTS_DIR}" \ + --results-history-name=AuthFlowTester \ + --no-performance-metrics \ + --num-flaky-test-attempts=1 \ + --timeout=30m || true + - name: Run All Multi User Tests + continue-on-error: true + if: ${{ ! inputs.is_pr }} + env: + FULL_API_RANGE: "28 29 30 31 32 33 34 35 36" + run: | + GCLOUD_RESULTS_DIR=authflowtester-multi-user-build-${{github.run_number}} + DEVICE_ARGS=() + for LEVEL in $FULL_API_RANGE; do + DEVICE_ARGS+=(--device "model=MediumPhone.arm,version=${LEVEL},locale=en,orientation=portrait") + done + + gcloud firebase test android run \ + --project mobile-apps-firebase-test \ + --type instrumentation \ + --use-orchestrator \ + --environment-variables clearPackageData=true \ + --app "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/debug/AuthFlowTester-debug.apk" \ + --test "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/androidTest/debug/AuthFlowTester-debug-androidTest.apk" \ + --test-targets "class com.salesforce.samples.authflowtester.MultiUserLoginTests" \ + "${DEVICE_ARGS[@]}" \ + --directories-to-pull=/sdcard \ + --results-dir="${GCLOUD_RESULTS_DIR}" \ + --results-history-name=AuthFlowTester \ + --no-performance-metrics \ + --num-flaky-test-attempts=1 \ + --timeout=15m || true - name: Copy Test Results continue-on-error: true if: success() || failure() env: - # Most used according to https://gs.statcounter.com/android-version-market-share/mobile-tablet/worldwide - PR_API_VERSION: "35" - FULL_API_RANGE: "28 29 30 31 32 33 34 35 36" IS_PR: ${{ inputs.is_pr }} run: | - LEVELS_TO_TEST=$FULL_API_RANGE + mkdir -p firebase_results + BUCKET="gs://test-lab-w87i9sz6q175u-kwp8ium6js0zw" - if $IS_PR ; then - LEVELS_TO_TEST=$PR_API_VERSION - fi + copy_results_by_api_level() { + local BUCKET_PATH=$1 + local OUTPUT_PREFIX=$2 - for LEVEL in $LEVELS_TO_TEST - do - GCLOUD_RESULTS_DIR=authflowtester-api-${LEVEL}-build-${{github.run_number}} - BUCKET_PATH="gs://test-lab-w87i9sz6q175u-kwp8ium6js0zw/${GCLOUD_RESULTS_DIR}" - - gsutil ls ${BUCKET_PATH} > /dev/null 2>&1 - if [ $? == 0 ] ; then - # Copy XML file for test reporting - if gsutil ls "${BUCKET_PATH}/*test_results_merged.xml" > /dev/null 2>&1; then - # Sharded runs produce test_results_merged.xml at top level - gsutil cp "${BUCKET_PATH}/*test_results_merged.xml" firebase_results/api_${LEVEL}_test_result.xml - else - gsutil cp "${BUCKET_PATH}/*/test_result_1.xml" firebase_results/api_${LEVEL}_test_result.xml - fi - fi + # Pass 1: copy original (non-rerun) results + for RESULT_FILE in $(gsutil ls "${BUCKET_PATH}/*/test_result_1.xml" 2>/dev/null | grep -v "rerun"); do + DEVICE_DIR=$(echo "${RESULT_FILE}" | sed 's|.*/\([^/]*\)/test_result_1.xml|\1|') + API_LEVEL=$(echo "${DEVICE_DIR}" | sed 's/.*-\([0-9]*\)-.*/\1/') + gsutil cp "${RESULT_FILE}" "firebase_results/${OUTPUT_PREFIX}_api_${API_LEVEL}_test_result.xml" + done + # Pass 2: merge rerun testcases into originals so check_retries detects flaky tests + for RESULT_FILE in $(gsutil ls "${BUCKET_PATH}/*/test_result_1.xml" 2>/dev/null | grep "rerun"); do + DEVICE_DIR=$(echo "${RESULT_FILE}" | sed 's|.*/\([^/]*\)/test_result_1.xml|\1|') + API_LEVEL=$(echo "${DEVICE_DIR}" | sed 's/.*-\([0-9]*\)-.*/\1/') + RERUN_TMP="firebase_results/${OUTPUT_PREFIX}_api_${API_LEVEL}_rerun_tmp.xml" + ORIG_FILE="firebase_results/${OUTPUT_PREFIX}_api_${API_LEVEL}_test_result.xml" + gsutil cp "${RESULT_FILE}" "${RERUN_TMP}" + python3 - "${ORIG_FILE}" "${RERUN_TMP}" "${ORIG_FILE}" << 'PYEOF' + import sys, xml.etree.ElementTree as ET + orig = ET.parse(sys.argv[1]) + rerun = ET.parse(sys.argv[2]) + def suite(t): + r = t.getroot() + return r if r.tag == 'testsuite' else r.find('testsuite') + os_el, rs_el = suite(orig), suite(rerun) + failed_keys = set() + for tc in os_el.findall('testcase'): + if tc.find('failure') is not None or tc.find('error') is not None: + failed_keys.add(f"{tc.get('name','')}|{tc.get('classname','')}|{tc.get('file','')}") + added = 0 + for tc in rs_el.findall('testcase'): + if f"{tc.get('name','')}|{tc.get('classname','')}|{tc.get('file','')}" in failed_keys: + os_el.append(tc) + added += 1 + os_el.set('tests', str(int(os_el.get('tests','0')) + added)) + with open(sys.argv[3], 'w') as f: + f.write(ET.tostring(orig.getroot(), encoding='unicode')) + PYEOF + rm "${RERUN_TMP}" done + } + + if $IS_PR ; then + BUCKET_PATH="${BUCKET}/authflowtester-pr-build-${{github.run_number}}" + if gsutil ls "${BUCKET_PATH}" > /dev/null 2>&1; then + copy_results_by_api_level "${BUCKET_PATH}" "pr" + fi + else + SINGLE_PATH="${BUCKET}/authflowtester-single-user-build-${{github.run_number}}" + if gsutil ls "${SINGLE_PATH}" > /dev/null 2>&1; then + copy_results_by_api_level "${SINGLE_PATH}" "single-user" + fi + + MULTI_PATH="${BUCKET}/authflowtester-multi-user-build-${{github.run_number}}" + if gsutil ls "${MULTI_PATH}" > /dev/null 2>&1; then + copy_results_by_api_level "${MULTI_PATH}" "multi-user" + fi + fi - name: Test Report uses: mikepenz/action-junit-report@v6 if: success() || failure() with: - check_name: ${{ inputs.lib }} Test Results - job_name: ${{ inputs.lib }} Test Results + check_name: AuthFlowTester Test Results + job_name: AuthFlowTester Test Results require_tests: true check_retries: true flaky_summary: true @@ -140,4 +219,16 @@ jobs: include_passed: true include_empty_in_summary: false simplified_summary: true - report_paths: 'firebase_results/**.xml' \ No newline at end of file + report_paths: 'firebase_results/**.xml' + - name: Archive APK + if: success() || failure() + uses: actions/upload-artifact@v4 + with: + name: AuthFlowTester-debug-${{ github.run_number }} + path: native/NativeSampleApps/AuthFlowTester/build/outputs/apk/debug/AuthFlowTester-debug.apk + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: ui-test-results + path: 'firebase_results/**.xml' \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index 9a5281d82a..f2cddb6625 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -26,9 +26,12 @@ */ package com.salesforce.androidsdk.auth +import android.accounts.Account +import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.os.Bundle import androidx.annotation.VisibleForTesting import com.salesforce.androidsdk.R.string.sf__generic_authentication_error import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title @@ -44,6 +47,7 @@ import com.salesforce.androidsdk.analytics.SalesforceAnalyticsManager import com.salesforce.androidsdk.app.Features.FEATURE_BIOMETRIC_AUTH import com.salesforce.androidsdk.app.Features.FEATURE_SCREEN_LOCK import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.app.SalesforceSDKManager.Companion.encryptionKey import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.addAuthorizationHeader import com.salesforce.androidsdk.auth.OAuth2.callIdentityService @@ -170,27 +174,30 @@ internal suspend fun onAuthFlowComplete( // Save the user account addAccount(account) - userAccountManager.createAccount(account) - userAccountManager.switchToUser(account) - // Init user logging - updateLoggingPrefs(account) + if (tokenMigration) { + userAccountManager.persistAccount(account) + } else { + userAccountManager.createAccount(account) + userAccountManager.switchToUser(account) - // Send User Switch Intent, create user and switch to user. - val numAuthenticatedUsers = userAccountManager.authenticatedUsers?.size ?: 0 - val userSwitchType = when { - // We've already authenticated the first user, so there should be one - numAuthenticatedUsers == 1 -> USER_SWITCH_TYPE_FIRST_LOGIN + // Init user logging + updateLoggingPrefs(account) - // Otherwise we're logging in with an additional user - numAuthenticatedUsers > 1 -> USER_SWITCH_TYPE_LOGIN + // Send User Switch Intent, create user and switch to user. + val numAuthenticatedUsers = userAccountManager.authenticatedUsers?.size ?: 0 + val userSwitchType = when { + // We've already authenticated the first user, so there should be one + numAuthenticatedUsers == 1 -> USER_SWITCH_TYPE_FIRST_LOGIN - // This should never happen but if it does, pass in the "unknown" value - else -> USER_SWITCH_TYPE_DEFAULT - } - userAccountManager.sendUserSwitchIntent(userSwitchType, null) + // Otherwise we're logging in with an additional user + numAuthenticatedUsers > 1 -> USER_SWITCH_TYPE_LOGIN + + // This should never happen but if it does, pass in the "unknown" value + else -> USER_SWITCH_TYPE_DEFAULT + } + userAccountManager.sendUserSwitchIntent(userSwitchType, null) - if (!tokenMigration) { // Kickoff the end of the flow before storing mobile policy to prevent launching // the main activity over/after the screen lock. startMainActivity() @@ -495,3 +502,34 @@ internal fun handleDuplicateUserAccount( } } } + +/** + * Persists account data to the Android AccountManager without changing + * the current user. This is needed for token migration of background + * users where [UserAccountManager.createAccount] cannot be used because + * it unconditionally calls [UserAccountManager.storeCurrentUserInfo]. + */ +private fun UserAccountManager.persistAccount( + userAccount: UserAccount, + accountType: String = SalesforceSDKManager.getInstance().accountType, + acctManager: AccountManager = AccountManager.get(SalesforceSDKManager.getInstance().appContext), +) { + val account = Account(userAccount.accountName, accountType) + val password = SalesforceSDKManager.encrypt(userAccount.refreshToken, encryptionKey) + val created = acctManager.addAccountExplicitly(account, password, /* userdata = */ Bundle()) + + // addAccountExplicitly fails if the account already exists, so update the refresh token. + if (!created) { + acctManager.setPassword(account, password) + } + + // Cache auth token to avoid an unnecessary refresh on first access. + acctManager.setAuthToken( + account, + /* authTokenType = */ AccountManager.KEY_AUTHTOKEN, + /* authToken = */ SalesforceSDKManager.encrypt(userAccount.authToken, encryptionKey), + ) + + // Persist all remaining user data via the existing public helper. + updateAccount(account, userAccount) +} diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index 2dbaf20b96..7b98a22e48 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -879,7 +879,7 @@ public TokenEndpointResponse(Map callbackUrlParams, List cookieSidClient = callbackUrlParams.get(COOKIE_SID_CLIENT); sidCookieName = callbackUrlParams.get(SID_COOKIE_NAME); parentSid = callbackUrlParams.get(PARENT_SID); - tokenFormat = callbackUrlParams.get(TOKEN_FORMAT); + tokenFormat = callbackUrlParams.getOrDefault(TOKEN_FORMAT, ""); scope = callbackUrlParams.get(SCOPE); // NB: beacon apps not supported with user agent flow so no beacon child fields expected diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt index 3dfe496ef4..40bfac037a 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesTest.kt @@ -27,6 +27,7 @@ package com.salesforce.androidsdk.auth import android.accounts.Account +import android.accounts.AccountManager import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -46,6 +47,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -272,6 +274,7 @@ class AuthenticationUtilitiesTest { // Given val userIdentity = createIdServiceResponse() coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + setupPersistAccountMocks() // When - tokenMigration is true callOnAuthFlowComplete(tokenMigration = true) @@ -288,6 +291,7 @@ class AuthenticationUtilitiesTest { val tokenResponse = createTokenEndpointResponse() val userIdentity = createIdServiceResponse() coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + setupPersistAccountMocks() // Create the expected UserAccount object val expectedAccount = UserAccountBuilder.getInstance() @@ -308,18 +312,20 @@ class AuthenticationUtilitiesTest { } @Test - fun testOnAuthFlowComplete_tokenMigration_shouldCreateAccount() = runTest { + fun testOnAuthFlowComplete_tokenMigration_shouldPersistAccount() = runTest { // Given val userIdentity = createIdServiceResponse() coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + setupPersistAccountMocks() // When - tokenMigration is true callOnAuthFlowComplete(tokenMigration = true) - // Then - account creation and user switch should still happen - verify(exactly = 1) { mockUserAccountManager.createAccount(any()) } - verify(exactly = 1) { mockUserAccountManager.switchToUser(any()) } + // Then - persistAccount should be used instead of createAccount/switchToUser verify(exactly = 1) { addAccount.invoke(any()) } + verify(exactly = 1) { mockUserAccountManager.updateAccount(any(), any()) } + verify(exactly = 0) { mockUserAccountManager.createAccount(any()) } + verify(exactly = 0) { mockUserAccountManager.switchToUser(any()) } } @Test @@ -327,6 +333,7 @@ class AuthenticationUtilitiesTest { // Given val userIdentity = createIdServiceResponse() coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + setupPersistAccountMocks() // When - tokenMigration is true callOnAuthFlowComplete(tokenMigration = true) @@ -370,6 +377,60 @@ class AuthenticationUtilitiesTest { verify(exactly = 0) { startMainActivity.invoke() } } + @Test + fun testOnAuthFlowComplete_tokenMigration_persistsNewAccountToAccountManager() = runTest { + // Given + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + val mockAcctManager = setupPersistAccountMocks(addAccountExplicitlyReturns = true) + + // When + callOnAuthFlowComplete(tokenMigration = true) + + // Then - new account should be added to AccountManager + verify { mockAcctManager.addAccountExplicitly( + match { it.name == "test@example.com (https://test.salesforce.com)" && it.type == "test_account_type" }, + any(), + any() + ) } + verify(exactly = 0) { mockAcctManager.setPassword(any(), any()) } + verify { mockAcctManager.setAuthToken(any(), eq(AccountManager.KEY_AUTHTOKEN), any()) } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_existingAccount_updatesPassword() = runTest { + // Given + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + val mockAcctManager = setupPersistAccountMocks(addAccountExplicitlyReturns = false) + + // When + callOnAuthFlowComplete(tokenMigration = true) + + // Then - existing account should have password updated + verify { mockAcctManager.setPassword(any(), any()) } + verify { mockAcctManager.setAuthToken(any(), eq(AccountManager.KEY_AUTHTOKEN), any()) } + verify { mockUserAccountManager.updateAccount(any(), any()) } + } + + @Test + fun testOnAuthFlowComplete_tokenMigration_doesNotCallNonMigrationFlowSteps() = runTest { + // Given + val userIdentity = createIdServiceResponse() + coEvery { fetchUserIdentity.invoke(any()) } returns userIdentity + setupPersistAccountMocks() + + // When + callOnAuthFlowComplete(tokenMigration = true) + + // Then - non-migration flow steps should NOT be called + verify(exactly = 0) { mockUserAccountManager.createAccount(any()) } + verify(exactly = 0) { mockUserAccountManager.switchToUser(any()) } + verify(exactly = 0) { startMainActivity.invoke() } + verify(exactly = 0) { updateLoggingPrefs.invoke(any()) } + verify(exactly = 0) { mockUserAccountManager.sendUserSwitchIntent(any(), any()) } + } + // endregion // region handleScreenLockPolicy Tests @@ -770,6 +831,26 @@ class AuthenticationUtilitiesTest { .build() } + private fun setupPersistAccountMocks(addAccountExplicitlyReturns: Boolean = true): AccountManager { + val mockAcctManager = mockk(relaxed = true) + + mockkObject(SalesforceSDKManager) + val mockSdkManager = mockk(relaxed = true) + every { SalesforceSDKManager.getInstance() } returns mockSdkManager + every { mockSdkManager.accountType } returns "test_account_type" + every { mockSdkManager.appContext } returns testContext + every { SalesforceSDKManager.encryptionKey } returns "test_encryption_key" + every { SalesforceSDKManager.encrypt(any(), any()) } answers { firstArg() } + + mockkStatic(AccountManager::class) + every { AccountManager.get(any()) } returns mockAcctManager + every { mockAcctManager.addAccountExplicitly(any(), any(), any()) } returns addAccountExplicitlyReturns + + every { mockUserAccountManager.updateAccount(any(), any()) } returns mockk() + + return mockAcctManager + } + private fun setupBiometricEnabledPrefs(mockSdkManager: SalesforceSDKManager) { val mockPrefs = mockk(relaxed = true) { every { getBoolean("bio_auth_enabled", false) } returns true diff --git a/native/NativeSampleApps/AuthFlowTester/README.md b/native/NativeSampleApps/AuthFlowTester/README.md new file mode 100644 index 0000000000..e935e20701 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/README.md @@ -0,0 +1,210 @@ +# AuthFlowTester + +A native Android sample app for the Salesforce Mobile SDK that serves as the primary vehicle for **UI automation testing** of authentication flows. The app displays OAuth credentials, token details, and user information after login, enabling end-to-end validation of the SDK's authentication infrastructure. + +## UI Test Coverage + +Tests are executed by GitHub Actions via `.github/workflows/reusable-ui-workflow.yaml` and run in [Firebase Test Lab](https://firebase.google.com/docs/test-lab) across all supported API levels using the AndroidX Test Orchestrator. + +- **PR runs** — a subset of representative tests on a single API level +- **Nightly runs** — all tests batched across API level. Multi-user tests run in separate batches with even/odd API level splitting to avoid credential collisions between adjacent levels. + +### Test Suites + +#### BootConfigLoginTests +Legacy login tests using the default Connected App (CA) opaque configuration from the app's `bootconfig.xml`. + +| Test | App Config | Scopes | Flow | Hybrid | +|------|-----------|--------|------|--------| +| `testCAOpaque_DefaultScopes_WebServerFlow` | CA Opaque | Default | Web Server | Yes | +| `testCAOpaque_DefaultScopes_WebServerFlow_NotHybrid` | CA Opaque | Default | Web Server | No | +| `testCAOpaque_DefaultScopes_UserAgentFlow` | CA Opaque | Default | User Agent | Yes | +| `testCAOpaque_DefaultScopes_UserAgentFlow_NotHybrid` | CA Opaque | Default | User Agent | No | + +#### CAScopeSelectionLoginTests +Connected App login tests with explicit scope selection across web server and user agent flows, both hybrid and non-hybrid. + +| Test | Scopes | Flow | Hybrid | +|------|--------|------|--------| +| `testCAOpaque_SubsetScopes_WebServerFlow` | Subset | Web Server | No | +| `testCAOpaque_AllScopes_WebServerFlow` | All | Web Server | Yes | +| `testCAOpaque_SubsetScopes_WebServerFlow_NotHybrid` | Subset | Web Server | No | +| `testCAOpaque_AllScopes_WebServerFlow_NotHybrid` | All | Web Server | No | +| `testCAOpaque_SubsetScopes_UserAgentFlow` | Subset | User Agent | Yes | +| `testCAOpaque_AllScopes_UserAgentFlow` | All | User Agent | Yes | +| `testCAOpaque_SubsetScopes_UserAgentFlow_NotHybrid` | Subset | User Agent | No | +| `testCAOpaque_AllScopes_UserAgentFlow_NotHybrid` | All | User Agent | No | + +#### ECALoginTests +External Client App (ECA) login tests for both opaque and JWT token formats with scope variations. + +| Test | App Config | Scopes | +|------|-----------|--------| +| `testECAOpaque_DefaultScopes` | ECA Opaque | Default | +| `testECAOpaque_SubsetScopes` | ECA Opaque | Subset | +| `testECAOpaque_AllScopes` | ECA Opaque | All | +| `testECAJwt_DefaultScopes` | ECA JWT | Default | +| `testECAJwt_SubsetScopes_NotHybrid` | ECA JWT | Subset | +| `testECAJwt_AllScopes` | ECA JWT | All | + +#### BeaconLoginTests +Beacon app login tests for lightweight authentication use cases, covering both opaque and JWT token formats. + +| Test | App Config | Scopes | +|------|-----------|--------| +| `testBeaconOpaque_DefaultScopes` | Beacon Opaque | Default | +| `testBeaconOpaque_SubsetScopes` | Beacon Opaque | Subset | +| `testBeaconOpaque_AllScopes` | Beacon Opaque | All | +| `testBeaconJwt_DefaultScopes` | Beacon JWT | Default | +| `testBeaconJwt_SubsetScopes` | Beacon JWT | Subset | +| `testBeaconJwt_AllScopes` | Beacon JWT | All | + +#### AdvancedAuthLoginTests (WIP) +Tests login via advanced authentication hosts that use Chrome Custom Tabs instead of the in-app WebView. Skipped on API ≤ 31 in Firebase Test Lab due to outdated Chrome. + +| Test | App Config | Scopes | Login Host | +|------|-----------|--------|------------| +| `testECAOpaque_DefaultScopes` | ECA Opaque | Default | Advanced Auth | + +#### RefreshTokenMigrationTests +Tests the SDK's refresh token migration flow, which exchanges tokens when an app's OAuth configuration changes (e.g., scope upgrades or connected app changes). Validates that tokens are replaced and the new tokens are functional. + +| Test | Description | +|------|-------------| +| `testMigrate_CA_AddMoreScopes` | Scope upgrade within the same CA JWT app | +| `testMigrate_ECA_AddMoreScopes` | Scope upgrade within the same ECA JWT app | +| `testMigrate_Beacon_AddMoreScopes` | Scope upgrade within the same Beacon JWT app | +| `testMigrate_CA_To_Beacon` | Migrate from CA Opaque to Beacon Opaque | +| `testMigrateBeacon_To_CA` | Migrate from Beacon Opaque to CA Opaque | +| `testMigrateCA_To_ECA` | Migrate CA → ECA → CA (with rollback) | +| `testMigrateCA_To_BeaconAndBack` | Migrate CA → Beacon → CA (with rollback) | +| `testMigrateBeaconOpaque_To_JWTAndBack` | Migrate Beacon Opaque → JWT → Opaque (with rollback) | + +#### MultiUserLoginTests +End-to-end tests for multi-user scenarios: logging in two users, switching between them, and validating that each user's tokens and OAuth configuration are preserved independently. + +| Test | Description | +|------|-------------| +| `testSameApp_SameScopes_uniqueTokens` | Two users on CA Opaque; validates unique tokens, user switching, and token refresh per user | +| `testSameApp_ECA_DifferentScopes` | Two users on ECA JWT with different scopes; validates scope isolation after switching | +| `testSameApp_Beacon_DifferentScopes` | Two users on Beacon Opaque with different scopes | +| `testFirstStatic_SecondDynamic_DifferentApps` | First user on boot config (CA), second on dynamic config (Beacon JWT) | +| `testFirstDynamic_SecondStatic_DifferentApps` | First user on dynamic config (ECA JWT), second on boot config (CA) | +| `testDifferentApps_differentScopes` | Two users on different apps with different scopes | +| `testMultiUser_tokenMigration` | Migrate one user's tokens while the other remains unaffected | +| `testMultiUser_tokenMigration_backgroundUser` | Migrate a background user's tokens; validate foreground user is unaffected and refresh works correctly post-switch | + +#### WelcomeLoginTests (WIP) +Planned tests for welcome discovery login flows with both regular and advanced auth hosts, using static and dynamic configurations. + +### Validation Per Test + +Each `loginAndValidate` call performs the following checks: +1. **User identity** — username matches the expected test user +2. **OAuth values** — consumer key, scopes granted, and token format (opaque vs JWT) match the app configuration +3. **Token format** — opaque tokens are exactly 112 characters; JWT tokens exceed that length; refresh tokens are 87 characters +4. **API request** — a REST API call succeeds with the issued tokens + +Migration tests additionally verify: +- Access and refresh tokens are **replaced** (not reused) +- A **token refresh** succeeds after revoking the new access token + +Multi-user tests additionally verify: +- Tokens are **unique** across users +- **User switching** preserves each user's tokens and OAuth configuration +- **Token refresh** targets the correct user's app after switching + +## Architecture + +### Test Infrastructure + +| Component | Description | +|-----------|-------------| +| `AuthFlowTest` | Abstract base class providing `loginAndValidate` and `migrateAndValidate` orchestration. Uses `ActivityScenarioRule` + `ComposeTestRule`. Assigns users based on API level to spread credential usage across Firebase Test Lab devices. | +| `UITestConfig` | Deserializes `ui_test_config.json` (from `shared/test/`) into typed enums: `KnownAppConfig`, `KnownLoginHostConfig`, `KnownUserConfig`, `ScopeSelection`. | + +### Page Objects + +| Page Object | Scope | Technology | +|------------|-------|------------| +| `BasePageObject` | Shared context and string resolution | Compose Test | +| `LoginPageObject` | Salesforce login WebView (username, password, login button, server picker, login options) | Espresso Web + Compose Test | +| `ChromeCustomTabPageObject` | Advanced auth login in Chrome Custom Tab (extends `LoginPageObject`) | UIAutomator | +| `LoginOptionsPageObject` | SDK Login Options screen (toggle web server flow, hybrid token, override boot config) | Compose Test | +| `AuthorizationPageObject` | OAuth "Allow" button handling after login or migration | UIAutomator | +| `AuthFlowTesterPageObject` | Main app screen (credentials, tokens, user switching, migration, API requests, revocation) | Compose Test + UIAutomator | + +### Configuration + +- **App configs** (`KnownAppConfig`): `ECA_OPAQUE`, `ECA_JWT`, `BEACON_OPAQUE`, `BEACON_JWT`, `CA_OPAQUE`, `CA_JWT` +- **Login hosts** (`KnownLoginHostConfig`): `REGULAR_AUTH` (in-app WebView), `ADVANCED_AUTH` (Chrome Custom Tab) +- **Scope options** (`ScopeSelection`): `EMPTY` (default/boot config scopes), `SUBSET` (all minus `sfap_api`), `ALL` +- **Users** (`KnownUserConfig`): `FIRST` through `FIFTH`, assigned per API level + +> **Note:** A valid `shared/test/ui_test_config.json` file with login host URLs, user credentials, and app configurations is required. See `shared/test/ui_test_config.json.sample` for the expected format. + +## Manual Testing + +The app is also useful for hands-on exploration and debugging of the SDK's authentication flows. After logging in, the main screen exposes several interactive features. + +### Login Options + +Accessible from the login screen before authenticating: + +1. Tap the **three-dot menu** (More Options) in the top bar +2. Tap **"Developer Support"** +3. Tap **"Login Options"** + +The Login Options screen allows you to override the default boot config for the current login attempt: + +- **Web Server Flow toggle** — enable or disable the web server OAuth flow (default: on). When off, the user agent flow is used. +- **Hybrid Auth Token toggle** — enable or disable hybrid authentication tokens (default: on). +- **Override Boot Config toggle** — when enabled, exposes fields to enter a custom **Consumer Key**, **Redirect URI**, and **Scopes** (space-separated). Tap **Save** to apply. This lets you test different app configurations (CA, ECA, Beacon) without rebuilding the app. + +### Change Server + +From the login screen: + +1. Tap the **three-dot menu** → **"Change Server"** +2. Select a login host from the server picker bottom sheet + +This switches between regular authentication (in-app WebView) and advanced authentication (Custom Tab) depending on the `.well-known` auth config of the host. + +### Main Screen + +The main screen shows expandable cards for the current user's data: + +- **User Credentials** — expand to inspect identity (username, user ID, org ID), OAuth client configuration (client ID, login domain), tokens (access token, refresh token, format, scopes), URLs, community info, domains/SIDs, cookies/security, and beacon fields. Sensitive values are masked by default; tap a row to reveal the full value. Long-press any row to copy its value to the clipboard. Tap the share icon on a card header to export the full section as JSON. +- **JWT Details** — appears only when the current user has a JWT access token. Shows decoded header (algorithm, key ID, token type, version) and payload (audience, expiration, issuer, subject, scopes, client ID). +- **OAuth Configuration** — displays the currently configured boot config values: consumer key, callback URL, and scopes. + +### Bottom Bar Actions + +The bottom bar provides three actions: + +- **Migrate Access Token** (key icon) — opens the token migration bottom sheet (see below). +- **Switch User** (person-add icon) — opens the SDK's account picker. Select an existing user to switch, or tap "Add New Account" to log in as a second user. +- **Logout** (logout icon) — presents a confirmation dialog, then logs out the current user via `SalesforceSDKManager.logout()`. + +### Revoke Access Token + +Tap **"Revoke Access Token"** to POST to `/services/oauth2/revoke` with the current access token. A dialog confirms success or failure. After revoking, make a REST API request to trigger the SDK's automatic token refresh. + +### Make REST API Request + +Tap **"Make REST API Request"** to send a lightweight REST request using the current tokens. A dialog shows success or failure, and an expandable "Response Details" section displays the full JSON response. + +### Token Migration + +The token migration sheet allows you to exchange a user's refresh token for a new one under a different connected app configuration. + +1. Tap the **key icon** in the bottom bar +2. If multiple users are logged in, select the target user via the radio buttons +3. Enter the new app's **Consumer Key**, **Callback URL**, and optionally **Scopes** + - Alternatively, tap the **JSON import icon** (top-right of the sheet) to paste a JSON object with keys `remoteConsumerKey`, `oauthRedirectURI`, and `oauthScopes`. The dialog auto-populates from the clipboard. +4. Tap **"Migrate Refresh Token"** +5. If the server requires authorization, tap **Allow** on the OAuth approval page +6. On success, the sheet dismisses and the main screen refreshes with the new tokens + +After migration, verify the new configuration by expanding the User Credentials card to check the updated client ID, scopes, and token format. + diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/BeaconLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/BeaconLoginTests.kt new file mode 100644 index 0000000000..c19e4ed89d --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/BeaconLoginTests.kt @@ -0,0 +1,89 @@ +/* + * 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 +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for login flows using Beacon app configurations. + * Beacon apps are lightweight authentication apps for specific use cases. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class BeaconLoginTests: AuthFlowTest() { + // region Beacon Opaque Tests + + // Login with Beacon opaque using default scopes and web server flow. + @Test + fun testBeaconOpaque_DefaultScopes() { + loginAndValidate(knownAppConfig = BEACON_OPAQUE) + } + + // Login with Beacon opaque using subset of scopes and web server flow. + @Test + fun testBeaconOpaque_SubsetScopes() { + loginAndValidate(knownAppConfig = BEACON_OPAQUE, scopeSelection = SUBSET) + } + + // Login with Beacon opaque using all scopes and web server flow. + @Test + fun testBeaconOpaque_AllScopes() { + loginAndValidate(knownAppConfig = BEACON_OPAQUE, scopeSelection = ALL) + } + + // endregion + + // region Beacon JWT Tests + + // Login with Beacon JWT using default scopes and web server flow. + @Test + fun testBeaconJwt_DefaultScopes() { + loginAndValidate(knownAppConfig = KnownAppConfig.BEACON_JWT) + } + + // Login with Beacon JWT using subset of scopes and web server flow. + @Test + fun testBeaconJwt_SubsetScopes() { + loginAndValidate(knownAppConfig = KnownAppConfig.BEACON_JWT, scopeSelection = SUBSET) + } + + // Login with Beacon JWT using all scopes and web server flow. + @Test + fun testBeaconJwt_AllScopes() { + loginAndValidate(knownAppConfig = KnownAppConfig.BEACON_JWT, scopeSelection = ALL) + } + + // endregion +} \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/BootConfigLoginTests.kt similarity index 56% rename from native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginTest.kt rename to native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/BootConfigLoginTests.kt index 1241bce06a..fea0d7d813 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/BootConfigLoginTests.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-present, salesforce.com, inc. + * 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 @@ -26,46 +26,50 @@ */ package com.salesforce.samples.authflowtester -import android.Manifest -import android.os.Build -import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.rule.GrantPermissionRule -import com.salesforce.samples.authflowtester.pageObjects.AuthFlowTesterPageObject -import com.salesforce.samples.authflowtester.pageObjects.LoginPageObject -import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig -import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig -import org.junit.Rule +import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE import org.junit.Test import org.junit.runner.RunWith +/** + * Legacy login tests using default scopes (CA opaque) from the BootConfig file. + */ @RunWith(AndroidJUnit4::class) @LargeTest -class LoginTest { - - @get:Rule(order = 0) - val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) - } else { - GrantPermissionRule.grant() +class BootConfigLoginTests: AuthFlowTest() { + // Login with CA opaque using default scopes and web server flow. + @Test + fun testCAOpaque_DefaultScopes_WebServerFlow() { + loginAndValidate(knownAppConfig = CA_OPAQUE) } - @get:Rule(order = 1) - val composeTestRule = createEmptyComposeRule() - - @get:Rule(order = 2) - val activityRule = ActivityScenarioRule(AuthFlowTesterActivity::class.java) + // Login with CA opaque using default scopes and (non-hybrid) web server flow. + @Test + fun testCAOpaque_DefaultScopes_WebServerFlow_NotHybrid() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + useHybridAuthToken = false, + ) + } - val loginPage = LoginPageObject(composeTestRule) - val app = AuthFlowTesterPageObject(composeTestRule) + // Login with CA opaque using default scopes and user agent flow. + @Test + fun testCAOpaque_DefaultScopes_UserAgentFlow() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + useWebServerFlow = false, + ) + } + // Login with CA opaque using default scopes and (non-hybrid) user agent flow. @Test - fun testBasicLogin() { - loginPage.login(KnownLoginHostConfig.REGULAR_AUTH, KnownUserConfig.FIRST) - app.waitForAppLoad() - app.validateUser(KnownLoginHostConfig.REGULAR_AUTH, KnownUserConfig.FIRST) - app.validateApiRequest() + fun testCAOpaque_DefaultScopes_UserAgentFlow_NotHybrid() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + useWebServerFlow = false, + useHybridAuthToken = false, + ) } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CAScopeSelectionLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CAScopeSelectionLoginTests.kt new file mode 100644 index 0000000000..795626b7ea --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CAScopeSelectionLoginTests.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.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.CA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for legacy login flows including: + * - Connected App (CA) configurations (traditional OAuth connected apps) + * - User agent flow tests + * - Non-hybrid flow tests + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class CAScopeSelectionLoginTests: AuthFlowTest() { + // region CA Web Server Flow Tests + + // Login with CA opaque using subset of scopes and web server flow. + @Test + fun testCAOpaque_SubsetScopes_WebServerFlow() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = SUBSET, + useHybridAuthToken = false, + ) + } + + // Login with CA opaque using all scopes and web server flow. + @Test + fun testCAOpaque_AllScopes_WebServerFlow() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = ALL, + ) + } + + // endregion + // region CA Non-hybrid Web Server Flow Tests + + // Login with CA opaque using subset of scopes and (non-hybrid) web server flow. + @Test + fun testCAOpaque_SubsetScopes_WebServerFlow_NotHybrid() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = SUBSET, + useHybridAuthToken = false, + ) + } + + // Login with CA opaque using all scopes and (non-hybrid) web server flow. + @Test + fun testCAOpaque_AllScopes_WebServerFlow_NotHybrid() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = ALL, + useHybridAuthToken = false, + ) + } + + // endregion + // region CA User Agent Flow Tests + + // Login with CA opaque using subset of scopes and user agent flow. + @Test + fun testCAOpaque_SubsetScopes_UserAgentFlow() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = SUBSET, + useWebServerFlow = false, + ) + } + + // Login with CA opaque using all scopes and user agent flow. + @Test + fun testCAOpaque_AllScopes_UserAgentFlow() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = ALL, + useWebServerFlow = false, + ) + } + + // endregion + // region CA Non-hybrid User Agent Flow Tests + + // Login with CA opaque using subset of scopes and (non-hybrid) user agent flow. + @Test + fun testCAOpaque_SubsetScopes_UserAgentFlow_NotHybrid() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = SUBSET, + useWebServerFlow = false, + useHybridAuthToken = false, + ) + } + + // Login with CA opaque using all scopes and (non-hybrid) user agent flow. + @Test + fun testCAOpaque_AllScopes_UserAgentFlow_NotHybrid() { + loginAndValidate( + knownAppConfig = CA_OPAQUE, + scopeSelection = ALL, + useWebServerFlow = false, + useHybridAuthToken = false, + ) + } + + // endregion +} \ No newline at end of file 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 new file mode 100644 index 0000000000..0766c2cdf1 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/ECALoginTests.kt @@ -0,0 +1,86 @@ +/* + * 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.ECA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for login flows using External Client App (ECA) configurations. + * ECA apps are first-party Salesforce apps that use enhanced authentication flows. + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class ECALoginTests: AuthFlowTest() { + + // region ECA Opaque Tests + + // Login with ECA opaque using default scopes and web server flow. + @Test +fun testECAOpaque_DefaultScopes() { + loginAndValidate(knownAppConfig = ECA_OPAQUE) + } + + // Login with ECA opaque using subset of scopes and web server flow. + @Test +fun testECAOpaque_SubsetScopes() { + loginAndValidate(knownAppConfig = ECA_OPAQUE, scopeSelection = SUBSET) + } + + // Login with ECA opaque using all scopes and web server flow. + @Test +fun testECAOpaque_AllScopes() { + loginAndValidate(knownAppConfig = ECA_OPAQUE, scopeSelection = ALL) + } + + // region ECA JWT Tests + + // Login with ECA JWT using default scopes and web server flow. + @Test +fun testECAJwt_DefaultScopes() { + loginAndValidate(knownAppConfig = ECA_JWT) + } + + // Login with ECA JWT using subset of scopes and web server flow. + @Test +fun testECAJwt_SubsetScopes_NotHybrid() { + loginAndValidate(knownAppConfig = ECA_JWT, scopeSelection = SUBSET) + } + + // Login with ECA JWT using all scopes and web server flow. + @Test +fun testECAJwt_AllScopes() { + loginAndValidate(knownAppConfig = ECA_JWT, scopeSelection = ALL) + } +} \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt new file mode 100644 index 0000000000..2753224fb8 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/MultiUserLoginTests.kt @@ -0,0 +1,304 @@ +/* + * 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 +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_JWT +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.SUBSET +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.EMPTY +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for multi-user login scenarios. + * + * Tests login with two users using various configurations: + * - Static vs dynamic app configuration + * - Same or different app types (opaque vs JWT) + * - Same or different scopes + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class MultiUserLoginTests: AuthFlowTest() { + + // Both users use the same default app type and default scopes, with additional token validation. + @Test + fun testSameApp_SameScopes_uniqueTokens() { + // Initial user + loginAndValidate(knownAppConfig = CA_OPAQUE) + val (userAccessToken, userRefreshToken) = app.getTokens() + + // Other user + loginOtherUserAndValidate(knownAppConfig = CA_OPAQUE) + val (otherUserAccessToken, otherUserRefreshToken) = app.getTokens() + + // Ensure unique tokens + assertNotEquals(userAccessToken, otherUserAccessToken) + assertNotEquals(userRefreshToken, otherUserRefreshToken) + + // Switch back to initial user + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = CA_OPAQUE, scopeSelection = EMPTY) + val (userSwitchAccessToken, userSwitchRefreshToken) = app.getTokens() + + // Ensure Correct Tokens Displayed + assertEquals(userAccessToken, userSwitchAccessToken) + assertEquals(userRefreshToken, userSwitchRefreshToken) + + // Ensure correct tokens refreshed + app.revokeAccessToken() + app.validateApiRequest() + val (userRevokeAccessToken, userRevokeRefreshToken) = app.getTokens() + assertNotEquals(userAccessToken, userRevokeAccessToken) + assertEquals(userRefreshToken, userRevokeRefreshToken) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = CA_OPAQUE, scopeSelection = EMPTY) + val (otherUserSwitchAccessToken, otherUserSwitchRefreshToken) = app.getTokens() + assert(otherUserAccessToken == otherUserSwitchAccessToken) + assert(otherUserRefreshToken == otherUserSwitchRefreshToken) + + // Ensure correct tokens refreshed + app.revokeAccessToken() + app.validateApiRequest() + val (otherUserRevokeAccessToken, otherUserRevokeRefreshToken) = app.getTokens() + assert(otherUserAccessToken != otherUserRevokeAccessToken) + assert(otherUserRefreshToken == otherUserRevokeRefreshToken) + } + + // Both users use the same ECA JWT app type and different scopes. + @Test + fun testSameApp_ECA_DifferentScopes() { + // Initial user + loginAndValidate( + knownAppConfig = ECA_JWT, + scopeSelection = SUBSET, + ) + + // Other user + loginOtherUserAndValidate( + knownAppConfig = ECA_JWT, + scopeSelection = ALL, + ) + + // Switch back to initial user + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = KnownAppConfig.ECA_JWT, scopeSelection = SUBSET) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = ECA_JWT, scopeSelection = ALL) + } + + // Both users use the same Beacon Opaque app type and different scopes. + @Test + fun testSameApp_Beacon_DifferentScopes() { + // Initial user + loginAndValidate( + knownAppConfig = BEACON_OPAQUE, + scopeSelection = EMPTY, + ) + + // Other user + loginOtherUserAndValidate( + knownAppConfig = BEACON_OPAQUE, + scopeSelection = SUBSET, + ) + + // Switch back to initial user + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = BEACON_OPAQUE, scopeSelection = EMPTY) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = BEACON_OPAQUE, scopeSelection = SUBSET) + } + + // First user boot config, second user dynamic config, different apps, same scopes (default). + @Test + fun testFirstStatic_SecondDynamic_DifferentApps() { + // Initial user + loginAndValidate(knownAppConfig = CA_OPAQUE) + + // Other user + loginOtherUserAndValidate(knownAppConfig = BEACON_JWT) + + // Switch back to initial user + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = CA_OPAQUE, scopeSelection = EMPTY) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = BEACON_JWT, scopeSelection = EMPTY) + } + + // First user dynamic config, second user boot config, different apps, same scopes (default). + @Test + fun testFirstDynamic_SecondStatic_DifferentApps() { + // Initial user + loginAndValidate(knownAppConfig = ECA_JWT) + + // Other user + loginOtherUserAndValidate(knownAppConfig = CA_OPAQUE) + + // Switch back to initial user + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = ECA_JWT, scopeSelection = EMPTY) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = CA_OPAQUE, scopeSelection = EMPTY) + } + + // Both users use different app types and differetn scopes. + @Test + fun testDifferentApps_differentScopes() { + // Initial user + loginAndValidate(knownAppConfig = BEACON_OPAQUE, scopeSelection = SUBSET) + + // Other user + loginOtherUserAndValidate(knownAppConfig = ECA_JWT) + + // Switch back to initial user + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = BEACON_OPAQUE, scopeSelection = SUBSET) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = ECA_JWT, scopeSelection = EMPTY) + } + + // Test MultiUser Token Migration. This test also demonstrates the app restart validation + // since tokens are read from disk, not memory, on user switch. + @Test + fun testMultiUser_tokenMigration() { + // Initial user + loginAndValidate(knownAppConfig = BEACON_JWT, scopeSelection = SUBSET) + val (userAccessToken, userRefreshToken) = app.getTokens() + + // Other user + loginOtherUserAndValidate(knownAppConfig = CA_OPAQUE) + + // Migrate current user + migrateAndValidate( + knownAppConfig = BEACON_OPAQUE, + knownUserConfig = otherUser, + ) + + // Switch back to initial user and assert unaltered. + switchToUserAndValidate(user) + app.validateOAuthValues(knownAppConfig = BEACON_JWT, scopeSelection = SUBSET) + val (userSwitchAccessToken, userSwitchRefreshToken) = app.getTokens() + assertEquals(userAccessToken, userSwitchAccessToken) + assertEquals(userRefreshToken, userSwitchRefreshToken) + } + + @Test + fun testMultiUser_tokenMigration_backgroundUser() { + // Initial user + loginAndValidate(knownAppConfig = CA_OPAQUE, scopeSelection = SUBSET) + val (userAccessToken, userRefreshToken) = app.getTokens() + + // Other user + loginOtherUserAndValidate(knownAppConfig = ECA_OPAQUE) + val (otherUserAccessToken, otherUserRefreshToken) = app.getTokens() + + // Migrate initial "user" while "otherUser" is current + app.migrateToNewApp( + knownAppConfig = ECA_JWT, + scopeSelection = EMPTY, + knownUserConfig = user, + ) + + // Validate nothing changed for "otherUser" before user switch + val (otherUserPostAccessToken, otherUserPostRefreshToken) = app.getTokens() + app.validateUser(knownLoginHostConfig = REGULAR_AUTH, knownUserConfig = otherUser) + app.validateOAuthValues(knownAppConfig = ECA_OPAQUE, scopeSelection = EMPTY) + assertEquals(otherUserAccessToken, otherUserPostAccessToken) + assertEquals(otherUserRefreshToken, otherUserPostRefreshToken) + + // Switch back to initial user + switchToUserAndValidate(user) + val (userPostAccessToken, userPostRefreshToken) = app.getTokens() + app.validateOAuthValues(knownAppConfig = ECA_JWT, scopeSelection = EMPTY) + assertNotEquals(userAccessToken, userPostAccessToken) + assertNotEquals(userRefreshToken, userPostRefreshToken) + + // Switch back to other user + switchToUserAndValidate(otherUser) + app.validateOAuthValues(knownAppConfig = ECA_OPAQUE, scopeSelection = EMPTY) + + // Assert refresh on correct app + app.revokeAccessToken() + app.validateApiRequest() + val (otherUserAccessTokenAfterRefresh, otherUserRefreshTokenAfterRefresh) = app.getTokens() + assertNotEquals(otherUserAccessToken, otherUserAccessTokenAfterRefresh) + assertEquals(otherUserRefreshToken, otherUserRefreshTokenAfterRefresh) + } + + private fun loginOtherUserAndValidate( + knownAppConfig: KnownAppConfig, + scopeSelection: ScopeSelection = EMPTY, + useWebServerFlow: Boolean = true, + useHybridAuthToken: Boolean = true, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + ) { + app.addNewAccount() + loginAndValidate( + knownAppConfig, + scopeSelection, + useWebServerFlow, + useHybridAuthToken, + knownLoginHostConfig, + knownUserConfig = otherUser, + ) + } + + private fun switchToUserAndValidate( + knownUserConfig: KnownUserConfig, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + ) { + app.switchToUser(knownUserConfig) + composeTestRule.waitForIdle() + app.validateUser(knownLoginHostConfig, knownUserConfig) + } +} \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/TokenMigrationTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt similarity index 61% rename from native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/TokenMigrationTest.kt rename to native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt index ee8f4b8510..e7f9d9ac13 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/TokenMigrationTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt @@ -26,44 +26,21 @@ */ package com.salesforce.samples.authflowtester -import android.Manifest -import android.os.Build -import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.rule.GrantPermissionRule -import com.salesforce.samples.authflowtester.pageObjects.AuthFlowTesterPageObject -import com.salesforce.samples.authflowtester.pageObjects.LoginOptionsPageObject -import com.salesforce.samples.authflowtester.pageObjects.LoginPageObject +import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig import com.salesforce.samples.authflowtester.testUtility.ScopeSelection -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +// TODO: remove loginAndValidate override when W-20524841 is fixed. + @RunWith(AndroidJUnit4::class) @LargeTest -class TokenMigrationTest { - - @get:Rule(order = 0) - val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) - } else { - GrantPermissionRule.grant() - } - - @get:Rule(order = 1) - val composeTestRule = createEmptyComposeRule() - - @get:Rule(order = 2) - val activityRule = ActivityScenarioRule(AuthFlowTesterActivity::class.java) - - val loginPage = LoginPageObject(composeTestRule) - val loginOptions = LoginOptionsPageObject(composeTestRule) - val app = AuthFlowTesterPageObject(composeTestRule) +class RefreshTokenMigrationTests: AuthFlowTest() { // region Migration within same app (scope upgrade) @@ -71,7 +48,7 @@ class TokenMigrationTest { @Test fun testMigrate_CA_AddMoreScopes() { loginAndValidate( - KnownAppConfig.CA_JWT, + knownAppConfig = KnownAppConfig.CA_JWT, scopeSelection = ScopeSelection.SUBSET, ) @@ -85,7 +62,7 @@ class TokenMigrationTest { @Test fun testMigrate_ECA_AddMoreScopes() { loginAndValidate( - KnownAppConfig.ECA_JWT, + knownAppConfig = KnownAppConfig.ECA_JWT, scopeSelection = ScopeSelection.SUBSET, ) @@ -99,7 +76,7 @@ class TokenMigrationTest { @Test fun testMigrate_Beacon_AddMoreScopes() { loginAndValidate( - KnownAppConfig.BEACON_JWT, + knownAppConfig = KnownAppConfig.BEACON_JWT, scopeSelection = ScopeSelection.SUBSET, ) @@ -116,7 +93,7 @@ class TokenMigrationTest { @Test fun testMigrateCA_To_Beacon() { loginAndValidate( - KnownAppConfig.CA_OPAQUE, + knownAppConfig = KnownAppConfig.CA_OPAQUE, ) migrateAndValidate( KnownAppConfig.BEACON_OPAQUE, @@ -127,7 +104,7 @@ class TokenMigrationTest { @Test fun testMigrateBeacon_To_CA() { loginAndValidate( - KnownAppConfig.BEACON_OPAQUE, + knownAppConfig = KnownAppConfig.BEACON_OPAQUE, ) migrateAndValidate( KnownAppConfig.CA_OPAQUE @@ -141,7 +118,7 @@ class TokenMigrationTest { @Test fun testMigrateCA_To_ECA() { loginAndValidate( - KnownAppConfig.CA_OPAQUE, + knownAppConfig = KnownAppConfig.CA_OPAQUE, ) migrateAndValidate( KnownAppConfig.ECA_OPAQUE, @@ -155,7 +132,7 @@ class TokenMigrationTest { @Test fun testMigrateCA_To_BeaconAndBack() { loginAndValidate( - KnownAppConfig.CA_OPAQUE + knownAppConfig = KnownAppConfig.CA_OPAQUE ) migrateAndValidate( KnownAppConfig.BEACON_OPAQUE @@ -169,7 +146,7 @@ class TokenMigrationTest { @Test fun testMigrateBeaconOpaque_To_JWTAndBack() { loginAndValidate( - KnownAppConfig.BEACON_OPAQUE + knownAppConfig = KnownAppConfig.BEACON_OPAQUE ) migrateAndValidate( KnownAppConfig.BEACON_JWT @@ -181,40 +158,21 @@ class TokenMigrationTest { // endregion - private fun loginAndValidate( - knownAppConfig: KnownAppConfig, - knownLoginHostConfig: KnownLoginHostConfig = KnownLoginHostConfig.REGULAR_AUTH, - knownUserConfig: KnownUserConfig = KnownUserConfig.FIRST, - scopeSelection: ScopeSelection = ScopeSelection.EMPTY, - ) { - loginPage.openLoginOptions() - loginOptions.setOverrideBootConfig(knownAppConfig, scopeSelection) - loginPage.login(knownLoginHostConfig, knownUserConfig) - app.waitForAppLoad() - - app.validateUser(knownLoginHostConfig, knownUserConfig) - app.validateOAuthValues(knownAppConfig, scopeSelection) - } - - private fun migrateAndValidate( + override fun loginAndValidate( knownAppConfig: KnownAppConfig, - knownLoginHostConfig: KnownLoginHostConfig = KnownLoginHostConfig.REGULAR_AUTH, - knownUserConfig: KnownUserConfig = KnownUserConfig.FIRST, - scopeSelection: ScopeSelection = ScopeSelection.EMPTY, + scopeSelection: ScopeSelection, + useWebServerFlow: Boolean, + useHybridAuthToken: Boolean, + knownLoginHostConfig: KnownLoginHostConfig, + knownUserConfig: KnownUserConfig, ) { - val (preAccessToken, preRefreshToken) = app.getTokens() - app.migrateToNewApp(knownAppConfig, scopeSelection) - val (postAccessToken, postRefreshToken) = app.getTokens() - - // Assert tokens are new - assert(preAccessToken != postAccessToken) - assert(preRefreshToken != postRefreshToken) - - app.validateUser(knownLoginHostConfig, knownUserConfig) - app.validateOAuthValues(knownAppConfig, scopeSelection) - - // Assert new tokens work - app.revokeAccessToken() - app.validateApiRequest() + super.loginAndValidate( + knownAppConfig, + scopeSelection, + useWebServerFlow, + useHybridAuthToken = false, + knownLoginHostConfig, + knownUserConfig = user, + ) } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt index 5b68a6e6be..a60008fd25 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt @@ -32,19 +32,28 @@ import android.content.Context.CLIPBOARD_SERVICE import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasContentDescription +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.performScrollTo +import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performSemanticsAction +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.samples.authflowtester.ALERT_POSITIVE_BUTTON_CONTENT_DESC import com.salesforce.samples.authflowtester.ALERT_TITLE_CONTENT_DESC import com.salesforce.samples.authflowtester.CREDS_SECTION_CONTENT_DESC import com.salesforce.samples.authflowtester.MIGRATE_TOKEN_BUTTON_CONTENT_DESC +import com.salesforce.samples.authflowtester.MIGRATE_USER_RADIO_CONTENT_DESC import com.salesforce.samples.authflowtester.R import com.salesforce.samples.authflowtester.REQUEST_BUTTON_CONTENT_DESC import com.salesforce.samples.authflowtester.REVOKE_BUTTON_CONTENT_DESC +import com.salesforce.samples.authflowtester.SCROLL_CONTAINER_CONTENT_DESC import com.salesforce.samples.authflowtester.components.ACCESS_TOKEN import com.salesforce.samples.authflowtester.components.CLIENT_ID import com.salesforce.samples.authflowtester.components.REFRESH_TOKEN @@ -59,6 +68,8 @@ import com.salesforce.samples.authflowtester.testUtility.testConfig import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.junit.Assert.assertEquals +import com.salesforce.androidsdk.R as sdkR + data class Tokens( val accessToken: String, @@ -71,18 +82,105 @@ data class Tokens( class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject(composeTestRule) { fun waitForAppLoad() { - waitForNode(CREDS_SECTION_CONTENT_DESC, timeoutMillis = TIMEOUT_MS * 5) + waitForNode(CREDS_SECTION_CONTENT_DESC, timeoutMillis = TIMEOUT_MS) } + fun switchToUser( + knownUserConfig: KnownUserConfig, + knownLoginHostConfig: KnownLoginHostConfig = KnownLoginHostConfig.REGULAR_AUTH, + ) { + openUserPicker() + + // Find the user's display name from authenticated accounts + val expectedUsername = testConfig.getUser(knownLoginHostConfig, knownUserConfig).username + val authenticatedUsers = UserAccountManager.getInstance().authenticatedUsers + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val displayName = authenticatedUsers?.find { it.username == expectedUsername }?.displayName + ?: throw AssertionError("User '$expectedUsername' not found in authenticated accounts") + + // Tap the user row in the picker + val userRow = device.findObject(UiSelector().textContains(displayName)) + if (!userRow.waitForExists(TIMEOUT_MS)) { + throw AssertionError("User '$displayName' not found on user picker") + } + userRow.click() + + // Wait for app to resume after picker closes, then re-send the user + // switch broadcast. The original broadcast fires while the activity is + // stopped (Recomposer paused), so the Compose state update may not + // trigger recomposition. Re-sending ensures Compose processes it. + waitForAppLoad() + UserAccountManager.getInstance().sendUserSwitchIntent( + UserAccountManager.USER_SWITCH_TYPE_DEFAULT, null + ) + composeTestRule.waitForIdle() + } + + fun addNewAccount() { + openUserPicker() + + // Tap "Add New Account" on the user picker (separate activity — use UiAutomator) + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val addNewAccountDesc = context.getString(sdkR.string.sf__add_new_account_content_description) + val addNewAccountButton = device.findObject(UiSelector().descriptionContains(addNewAccountDesc)) + if (!addNewAccountButton.waitForExists(TIMEOUT_MS)) { + throw AssertionError("Add New Account button not found on user picker") + } + addNewAccountButton.click() + } + + private fun openUserPicker() { + val switchUserDesc = getString(R.string.switch_user) + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val pickerDesc = context.getString(sdkR.string.sf__account_picker_content_description) + val picker = device.findObject(UiSelector().descriptionContains(pickerDesc)) + + var found = false + for (i in 1..3) { + if (picker.exists()) { + found = true + break + } + + try { + waitForNode(switchUserDesc) + composeTestRule.onNodeWithContentDescription(switchUserDesc) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() + } catch (e: Throwable) { + // The icon might not be found if the picker is already slowly opening and covering the screen + } + + if (picker.waitForExists(TIMEOUT_MS)) { + found = true + break + } + } + if (!found) { + throw AssertionError("User picker not found") + } + } + + fun isAppLoaded(): Boolean = + try { + composeTestRule.onAllNodesWithContentDescription(CREDS_SECTION_CONTENT_DESC) + .fetchSemanticsNodes().isNotEmpty() + } catch (_: IllegalStateException) { + false // Compose hierarchy temporarily unavailable + } + fun revokeAccessToken() { - waitForNode(REVOKE_BUTTON_CONTENT_DESC) + composeTestRule.onNodeWithContentDescription(SCROLL_CONTAINER_CONTENT_DESC) + .performScrollToNode(hasContentDescription(REVOKE_BUTTON_CONTENT_DESC)) + + waitForNode(REVOKE_BUTTON_CONTENT_DESC, timeoutMillis = TIMEOUT_MS) // Use performSemanticsAction instead of performClick because // performScrollTo doesn't trigger nested scroll, leaving the button // behind the collapsed top bar where touch input gets intercepted. composeTestRule.onNodeWithContentDescription(REVOKE_BUTTON_CONTENT_DESC) .performSemanticsAction(SemanticsActions.OnClick) - waitForNode(ALERT_TITLE_CONTENT_DESC) + waitForNode(ALERT_TITLE_CONTENT_DESC, timeoutMillis = TIMEOUT_MS) composeTestRule.onNodeWithContentDescription(ALERT_TITLE_CONTENT_DESC) .assertTextEquals(getString(R.string.revoke_successful)) @@ -92,11 +190,11 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject } fun validateApiRequest() { - waitForNode(REQUEST_BUTTON_CONTENT_DESC) + waitForNode(REQUEST_BUTTON_CONTENT_DESC, timeoutMillis = TIMEOUT_MS) composeTestRule.onNodeWithContentDescription(REQUEST_BUTTON_CONTENT_DESC) .performSemanticsAction(SemanticsActions.OnClick) - waitForNode(ALERT_TITLE_CONTENT_DESC) + waitForNode(ALERT_TITLE_CONTENT_DESC, timeoutMillis = TIMEOUT_MS) composeTestRule.onNodeWithContentDescription(ALERT_TITLE_CONTENT_DESC) .assertTextEquals(getString(R.string.request_successful)) @@ -117,20 +215,62 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject fun validateUser(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { val expected = testConfig.getUser(knownLoginHostConfig, knownUserConfig) - expandUserCredentialsSection() + waitForNode(CREDS_SECTION_CONTENT_DESC) + + // Wait for the UI to update asynchronously after login or user switch. + // The view may be recreated and collapsed when the current user state updates. + try { + composeTestRule.waitUntil(TIMEOUT_MS) { + try { + val nodes = composeTestRule.onAllNodesWithContentDescription(USERNAME).fetchSemanticsNodes() + val isVisible = nodes.isNotEmpty() + var isMatch = false + + if (isVisible) { + val config = nodes.first().config + if (config.contains(SemanticsProperties.Text)) { + isMatch = config[SemanticsProperties.Text].last().text == expected.username + } + } else { + composeTestRule.onNodeWithContentDescription(CREDS_SECTION_CONTENT_DESC).performClick() + composeTestRule.waitForIdle() + } + + isMatch + } catch (_: Exception) { + false + } + } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting for username to show \"${expected.username}\"", e) + } assertEquals(expected.username, getText(USERNAME)) } fun validateOAuthValues(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection) { val expected = testConfig.getApp(knownAppConfig) + val (accessToken, refreshToken) = getTokens() - expandUserCredentialsSection() + expandUserCredentialsSection(targetNode = CLIENT_ID) assertEquals(expected.consumerKey, getSensitiveValue(CLIENT_ID)) assertEquals(expected.expectedScopesGranted(scopeSelection), getText(SCOPES)) assertEquals(expected.expectedTokenFormat, getText(TOKEN_FORMAT)) + if (expected.issuesJwt) { + assert(accessToken.length > refreshToken.length) { + "JWT access token (${accessToken.length}) should be longer than refresh token (${refreshToken.length})" + } + } else { + assert(accessToken.isNotEmpty()) { "Expected non-empty opaque access token" } + } + assert(refreshToken.isNotEmpty()) { "Expected non-empty refresh token" } } - fun migrateToNewApp(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection) { + fun migrateToNewApp( + knownAppConfig: KnownAppConfig, + scopeSelection: ScopeSelection, + knownUserConfig: KnownUserConfig? = null, + knownLoginHostConfig: KnownLoginHostConfig = KnownLoginHostConfig.REGULAR_AUTH, + ) { val (_, consumerKey, redirectUri, scopes) = testConfig.getAppWithRequestScopes(knownAppConfig, scopeSelection) val jsonApp = buildJsonObject { @@ -153,70 +293,111 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject val migrateDesc = getString(R.string.migrate_access_token) waitForNode(migrateDesc) composeTestRule.onNodeWithContentDescription(migrateDesc) - .performClick() + .performSemanticsAction(SemanticsActions.OnClick) composeTestRule.waitForIdle() + // Select the target user if specified (user list only visible with multiple users) + if (knownUserConfig != null) { + val expectedUsername = testConfig.getUser(knownLoginHostConfig, knownUserConfig).username + val radioDesc = MIGRATE_USER_RADIO_CONTENT_DESC + expectedUsername + waitForNode(radioDesc) + composeTestRule.onNodeWithContentDescription(radioDesc) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() + } + // Wait for bottom sheet, then tap JSON import val jsonDesc = getString(R.string.json_content_description) waitForNode(jsonDesc) composeTestRule.onNodeWithContentDescription(jsonDesc) - .performClick() + .performSemanticsAction(SemanticsActions.OnClick) composeTestRule.waitForIdle() // Tap import button waitForNode(ALERT_POSITIVE_BUTTON_CONTENT_DESC) composeTestRule.onNodeWithContentDescription(ALERT_POSITIVE_BUTTON_CONTENT_DESC) - .performClick() + .performSemanticsAction(SemanticsActions.OnClick) composeTestRule.waitForIdle() // Tap migrate button waitForNode(MIGRATE_TOKEN_BUTTON_CONTENT_DESC) composeTestRule.onNodeWithContentDescription(MIGRATE_TOKEN_BUTTON_CONTENT_DESC) - .performClick() + .performSemanticsAction(SemanticsActions.OnClick) AuthorizationPageObject(composeTestRule).tapAllowAfterMigration() - // Wait for migration to complete (bottom sheet dismisses) - waitForNodeGone(MIGRATE_TOKEN_BUTTON_CONTENT_DESC) + // Wait for migration to complete and sheet to auto-dismiss. + // For background user migration, the sheet won't auto-dismiss + // (compose recomposer was paused), so tap the close button instead. + val closeDesc = getString(R.string.close_content_description) + try { + waitForNodeGone(closeDesc) + } catch (_: Exception) { + composeTestRule.onNodeWithContentDescription(closeDesc) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() + waitForNodeGone(closeDesc) + } // Wait for the app UI to refresh with new token data waitForAppLoad() } - private fun expandUserCredentialsSection() { + private fun expandUserCredentialsSection(targetNode: String = ACCESS_TOKEN) { waitForNode(CREDS_SECTION_CONTENT_DESC) - val alreadyExpanded = composeTestRule.onAllNodesWithContentDescription(ACCESS_TOKEN) - .fetchSemanticsNodes().isNotEmpty() - if (!alreadyExpanded) { - composeTestRule.onNodeWithContentDescription(CREDS_SECTION_CONTENT_DESC) - .performClick() - composeTestRule.waitForIdle() - waitForNode(ACCESS_TOKEN) + // Wait until the target node is visible, expanding the card if needed. + // The key() block in TesterUI may recreate UserCredentialsView (collapsing + // the card) between expansion and access, so poll until it stabilizes. + try { + composeTestRule.waitUntil(TIMEOUT_MS) { + try { + val visible = composeTestRule.onAllNodesWithContentDescription(targetNode) + .fetchSemanticsNodes().isNotEmpty() + if (!visible) { + composeTestRule.onNodeWithContentDescription(CREDS_SECTION_CONTENT_DESC) + .performClick() + composeTestRule.waitForIdle() + } + visible + } catch (_: Exception) { + false + } + } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting to expand credentials section for node: \"$targetNode\"", e) } } /** Wait for a node with the given content description to exist. */ private fun waitForNode(contentDesc: String, timeoutMillis: Long = TIMEOUT_MS) { - composeTestRule.waitUntil(timeoutMillis) { - try { - composeTestRule.onAllNodesWithContentDescription(contentDesc) - .fetchSemanticsNodes().isNotEmpty() - } catch (_: IllegalStateException) { - false // Compose hierarchy temporarily unavailable + try { + composeTestRule.waitUntil(timeoutMillis) { + try { + composeTestRule.onAllNodesWithContentDescription(contentDesc) + .fetchSemanticsNodes().isNotEmpty() + } catch (_: IllegalStateException) { + false // Compose hierarchy temporarily unavailable + } } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${timeoutMillis}ms waiting for node: \"$contentDesc\"", e) } } /** Wait for a node with the given content description to disappear. */ private fun waitForNodeGone(contentDesc: String, timeoutMillis: Long = TIMEOUT_MS) { - composeTestRule.waitUntil(timeoutMillis) { - try { - composeTestRule.onAllNodesWithContentDescription(contentDesc) - .fetchSemanticsNodes().isEmpty() - } catch (_: IllegalStateException) { - false + try { + composeTestRule.waitUntil(timeoutMillis) { + try { + composeTestRule.onAllNodesWithContentDescription(contentDesc) + .fetchSemanticsNodes().isEmpty() + } catch (_: IllegalStateException) { + false + } } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${timeoutMillis}ms waiting for node to disappear: \"$contentDesc\"", e) } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt index fddb10f36b..20fcaefec0 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt @@ -32,6 +32,9 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH import com.salesforce.androidsdk.R as sdkR private const val TAG = "AuthorizationPageObject" @@ -53,20 +56,62 @@ class AuthorizationPageObject(composeTestRule: ComposeTestRule) : BasePageObject * polls for the Allow button. Returns early if the main app UI * appears (approval was auto-granted or previously remembered). */ - fun tapAllowAfterLogin() { + fun tapAllowAfterLogin(knownLoginHostConfig: KnownLoginHostConfig) { // Let the WebView redirect to authorization page. - Thread.sleep(TIMEOUT_MS * 2) + Thread.sleep(SLEEP_TIME_MS) + + when(knownLoginHostConfig) { + REGULAR_AUTH -> tapAllowInWebView() + ADVANCED_AUTH -> { + dismissSavePasswordDialog() + tapAllowInCustomTab() + } + } + } + + private fun dismissSavePasswordDialog() { + val infoBar = device.findObject( + UiSelector().resourceId("com.android.chrome:id/infobar_message") + ) + val neverButton = device.findObject( + UiSelector().resourceId("com.android.chrome:id/button_secondary") + ) + infoBar.waitForExists(TIMEOUT_MS) + if (neverButton.waitForExists(TIMEOUT_MS)) { + neverButton.click() + } + } + + /** Scrolls within the Custom Tab to find and tap Allow. */ + private fun tapAllowInCustomTab() { + val app = AuthFlowTesterPageObject(composeTestRule) + swipeUp() + + repeat(MAX_RETRIES) { + if (app.isAppLoaded()) { + Log.i(TAG, "Left login screen — no approval needed.") + return + } + + if (allowButton.waitForExists(TIMEOUT_MS)) { + allowButton.click() + Log.i(TAG, "Tapped Allow after login.") + return + } + } + } + + /** Original flow: scrolls and polls for Allow in the in-app WebView. */ + private fun tapAllowInWebView() { swipeUp() repeat(MAX_RETRIES) { - // "More Options" is in the LoginActivity top bar. - // Once it disappears, we've left the login screen. if (!loginActivityExists()) { Log.i(TAG, "Left login screen — no approval needed.") return } - if (allowButton.waitForExists(TIMEOUT_MS * 2)) { + if (allowButton.waitForExists(TIMEOUT_MS)) { allowButton.click() Log.i(TAG, "Tapped Allow after login.") return @@ -82,7 +127,7 @@ class AuthorizationPageObject(composeTestRule: ComposeTestRule) : BasePageObject */ fun tapAllowAfterMigration() { // Wait for the page to load, swipe, then poll for the Allow button. - allowButton.waitForExists(TIMEOUT_MS * 5) + allowButton.waitForExists(TIMEOUT_MS) swipeUp() repeat(MAX_RETRIES) { @@ -93,7 +138,7 @@ class AuthorizationPageObject(composeTestRule: ComposeTestRule) : BasePageObject return } - if (allowButton.waitForExists(TIMEOUT_MS * 2)) { + if (allowButton.waitForExists(TIMEOUT_MS)) { allowButton.click() Log.i(TAG, "Tapped Allow after migration.") return @@ -110,7 +155,7 @@ class AuthorizationPageObject(composeTestRule: ComposeTestRule) : BasePageObject /* startY = */ displayHeight * 3 / 4, /* endX = */ displayWidth / 2, /* endY = */ displayHeight / 4, - /* steps = */ 10, + /* steps = */ 30, ) } 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 b96e9e7fbb..fb005fbed3 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 @@ -27,6 +27,7 @@ package com.salesforce.samples.authflowtester.pageObjects import android.content.Context +import android.provider.Settings import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.test.platform.app.InstrumentationRegistry @@ -36,6 +37,18 @@ abstract class BasePageObject(val composeTestRule: ComposeTestRule) { fun getString(id: Int) = context.getString(id) companion object { - const val TIMEOUT_MS: Long = 2_000 + val isFtl: Boolean by lazy { + Settings.System.getString( + InstrumentationRegistry.getInstrumentation().targetContext.contentResolver, + /* name = */ "firebase.test.lab" + ) == "true" + } + val TIMEOUT_MS: Long by lazy { + if (isFtl) 15_000 else 5_000 + } + + val SLEEP_TIME_MS: Long by lazy { + if (isFtl) 5_000 else 2_500 + } } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/CustomTabPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt similarity index 54% rename from native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/CustomTabPageObject.kt rename to native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt index 7fdd056f06..b34bff7139 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/CustomTabPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt @@ -26,54 +26,85 @@ */ package com.salesforce.samples.authflowtester.pageObjects +import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig +import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig -private const val TIMEOUT = 5_000L +private const val RETRY_COUNT = 3 /** * Handles Custom Tab interactions. * UiAutomator is required here because the browser (often Chrome) runs in a * separate process that Espresso and Compose Test APIs cannot access. */ -class CustomTabPageObject { +class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObject(composeTestRule) { private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - fun handleSignIn() { + override fun login(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + skipGoogleSignIn() + super.login(knownLoginHostConfig, knownUserConfig) + } + + override fun setUsername(name: String) { + val usernameField = device.findObject( + UiSelector().className("android.widget.EditText").instance(0) + ) + if (!usernameField.waitForExists(TIMEOUT_MS)) { + throw AssertionError("Username field not found in Custom Tab") + } + usernameField.clearTextField() + usernameField.setText(name) + } + + override fun setPassword(password: String) { + val passwordField = device.findObject( + UiSelector().className("android.widget.EditText").instance(1) + ) + if (!passwordField.waitForExists(TIMEOUT_MS)) { + throw AssertionError("Password field not found in Custom Tab") + } + passwordField.clearTextField() + passwordField.setText(password) + } + + override fun tapLogin() { + val 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") + } + loginButton.click() + } + + fun skipGoogleSignIn() { val continueButton = device.findObject( UiSelector().resourceId("com.android.chrome:id/signin_fre_dismiss_button") ) val noButton = device.findObject( UiSelector().resourceId("com.android.chrome:id/negative_button") ) - val toolbar = device.findObject( - UiSelector().resourceId("com.android.chrome:id/toolbar") + val legacyContinueButton = device.findObject( + UiSelector().resourceId("com.android.chrome:id/terms_accept") ) - if (continueButton.waitForExists(TIMEOUT * 2)) { - continueButton.click() - if (noButton.waitForExists(TIMEOUT)) { - noButton.click() + repeat(times = RETRY_COUNT) { + if (continueButton.waitForExists(TIMEOUT_MS)) { + continueButton.click() + return@repeat + } else if (legacyContinueButton.waitForExists(TIMEOUT_MS)) { + legacyContinueButton.click() + return@repeat } } - if (toolbar.waitForExists(TIMEOUT)) { - dismissSavePasswordDialog() - } - } - - fun dismissSavePasswordDialog() { - val infoBar = device.findObject( - UiSelector().resourceId("com.android.chrome:id/infobar_message") - ) - val neverButton = device.findObject( - UiSelector().resourceId("com.android.chrome:id/button_secondary") - ) - infoBar.waitForExists(TIMEOUT) - if (neverButton.waitForExists(TIMEOUT)) { - neverButton.click() + if (noButton.waitForExists(TIMEOUT_MS)) { + noButton.click() + return } } @@ -81,7 +112,7 @@ class CustomTabPageObject { val closeButton = device.findObject( UiSelector().resourceId("com.android.chrome:id/close_button") ) - if (closeButton.waitForExists(TIMEOUT)) { + if (closeButton.waitForExists(TIMEOUT_MS)) { closeButton.click() } } 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 536fdd6321..fc496b3cbb 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 @@ -37,6 +37,8 @@ import androidx.test.espresso.Espresso.closeSoftKeyboard import com.salesforce.androidsdk.R import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig import com.salesforce.samples.authflowtester.testUtility.ScopeSelection +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.ALL +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.EMPTY import com.salesforce.samples.authflowtester.testUtility.testConfig /** @@ -60,7 +62,7 @@ class LoginOptionsPageObject(composeTestRule: ComposeTestRule): BasePageObject(c getString(R.string.sf__login_options_hybrid_toggle_content_description) ) - fun setOverrideBootConfig(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection = ScopeSelection.ALL) { + fun setOverrideBootConfig(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection = ALL) { enableOverrideBootConfig() with(testConfig.getApp(knownAppConfig)) { @@ -72,9 +74,11 @@ class LoginOptionsPageObject(composeTestRule: ComposeTestRule): BasePageObject(c getString(R.string.sf__login_options_redirect_uri_field_content_description) ).performTextReplacement(redirectUri) - composeTestRule.onNodeWithContentDescription( - getString(R.string.sf__login_options_scopes_field_content_description) - ).performTextReplacement(scopesToRequest(scopeSelection)) + if (scopeSelection != EMPTY) { + composeTestRule.onNodeWithContentDescription( + getString(R.string.sf__login_options_scopes_field_content_description) + ).performTextReplacement(scopesToRequest(scopeSelection)) + } } saveOverrideBootConfig() 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 16fbf57a56..51113c42e6 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 @@ -26,6 +26,7 @@ */ 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 @@ -41,6 +42,9 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.findElement import androidx.test.espresso.web.webdriver.DriverAtoms.webClick import androidx.test.espresso.web.webdriver.DriverAtoms.webKeys import androidx.test.espresso.web.webdriver.Locator +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import com.salesforce.androidsdk.R import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig @@ -55,22 +59,16 @@ private const val LOGIN_BUTTON_ID = "Login" * Uses Espresso WebView APIs since the login form is an in-app WebView * embedded via AndroidView in the SDK's LoginActivity Compose layout. */ -class LoginPageObject( - composeTestRule: ComposeTestRule, - val isAdvancedAuth: Boolean = false, -): BasePageObject(composeTestRule) { +open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(composeTestRule) { - fun login(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + private val device by lazy { UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) } + + open fun login(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { val (username, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) setUsername(username) setPassword(password) tapLogin() - - if (isAdvancedAuth) { - CustomTabPageObject().handleSignIn() - } - - AuthorizationPageObject(composeTestRule).tapAllowAfterLogin() + AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(knownLoginHostConfig) } fun openLoginOptions() { @@ -84,20 +82,65 @@ class LoginPageObject( .performClick() composeTestRule.waitForIdle() + // Wait for the AlertDialog to be fully rendered and ready + try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + onView(withText(getString(R.string.sf__dev_support_login_options_title))) + .inRoot(isDialog()) + .check { _, _ -> } + true + } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting for Developer Support dialog to appear", e) + } + // Tap "Login Options" in the native AlertDialog (not Compose) onView(withText(getString(R.string.sf__dev_support_login_options_title))) .inRoot(isDialog()) .perform(click()) // Wait for LoginOptionsActivity's Compose content to render. - composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - composeTestRule.onAllNodesWithContentDescription( - getString(R.string.sf__login_options_dynamic_config_toggle_content_description) - ).fetchSemanticsNodes().isNotEmpty() + try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + composeTestRule.onAllNodesWithContentDescription( + getString(R.string.sf__login_options_dynamic_config_toggle_content_description) + ).fetchSemanticsNodes().isNotEmpty() + } + } catch (e: ComposeTimeoutException) { + throw AssertionError("Timed out after ${TIMEOUT_MS}ms waiting for Login Options screen to render", e) + } + Thread.sleep(TIMEOUT_MS / 4) + } + + fun changeServer(knownLoginHostConfig: KnownLoginHostConfig) { + val url = testConfig.getLoginHost(knownLoginHostConfig).url + + // Tap "More Options" three-dot menu (Compose IconButton) + composeTestRule.onNodeWithContentDescription(getString(R.string.sf__more_options)) + .performClick() + composeTestRule.waitForIdle() + + // Tap "Change Server" dropdown menu item + composeTestRule.onNodeWithText(getString(R.string.sf__pick_server)) + .performClick() + + // Wait for server picker bottom sheet to appear + try { + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + composeTestRule.onAllNodesWithContentDescription( + getString(R.string.sf__server_picker_content_description) + ).fetchSemanticsNodes().isNotEmpty() + } + } 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 + composeTestRule.onNodeWithText(url, substring = true).performClick() + composeTestRule.waitForIdle() } - private fun setUsername(name: String) { + open fun setUsername(name: String) { retryWebAction { onWebView().withElement(findElement(Locator.ID, USERNAME_ID)) .perform(clearElement()) @@ -105,7 +148,7 @@ class LoginPageObject( } } - private fun setPassword(password: String) { + open fun setPassword(password: String) { retryWebAction { onWebView().withElement(findElement(Locator.ID, PASSWORD_ID)) .perform(clearElement()) @@ -113,16 +156,45 @@ class LoginPageObject( } } - private fun tapLogin() { + open fun tapLogin() { retryWebAction { onWebView().withElement(findElement(Locator.ID, LOGIN_BUTTON_ID)) .perform(webClick()) } } + /** Enters credentials and taps login in a Chrome Custom Tab via UIAutomator. */ + private fun loginInCustomTab(username: String, password: String) { + val usernameField = device.findObject( + UiSelector().className("android.widget.EditText").instance(0) + ) + if (!usernameField.waitForExists(TIMEOUT_MS)) { + throw AssertionError("Username field not found in Custom Tab") + } + usernameField.clearTextField() + usernameField.setText(username) + + val passwordField = device.findObject( + UiSelector().className("android.widget.EditText").instance(1) + ) + if (!passwordField.waitForExists(TIMEOUT_MS)) { + throw AssertionError("Password field not found in Custom Tab") + } + passwordField.clearTextField() + passwordField.setText(password) + + val 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") + } + loginButton.click() + } + /** Retries a WebView action until it succeeds or times out. */ private fun retryWebAction( - timeoutMs: Long = TIMEOUT_MS * 5, + timeoutMs: Long = TIMEOUT_MS, action: () -> T, ): T { val endTime = System.currentTimeMillis() + timeoutMs 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 new file mode 100644 index 0000000000..b24853ef45 --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -0,0 +1,159 @@ +/* + * 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.testUtility + +import android.Manifest +import android.os.Build +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.samples.authflowtester.AuthFlowTesterActivity +import com.salesforce.samples.authflowtester.pageObjects.AuthFlowTesterPageObject +import com.salesforce.samples.authflowtester.pageObjects.AuthorizationPageObject +import com.salesforce.samples.authflowtester.pageObjects.LoginOptionsPageObject +import com.salesforce.samples.authflowtester.pageObjects.ChromeCustomTabPageObject +import com.salesforce.samples.authflowtester.pageObjects.LoginPageObject +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.EMPTY +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE +import org.junit.After +import org.junit.Rule + +abstract class AuthFlowTest { + @get:Rule(order = 0) + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + + @get:Rule(order = 1) + val composeTestRule = createEmptyComposeRule() + + @get:Rule(order = 2) + val activityRule = ActivityScenarioRule(AuthFlowTesterActivity::class.java) + + val loginOptions = LoginOptionsPageObject(composeTestRule) + val app = AuthFlowTesterPageObject(composeTestRule) + + val user: KnownUserConfig by lazy { + val minSdk = InstrumentationRegistry.getInstrumentation().targetContext + .applicationInfo.minSdkVersion + val userNumber = (Build.VERSION.SDK_INT - minSdk) % KnownUserConfig.values().count() + KnownUserConfig.values()[userNumber] + } + + // For MultiUser tests + val otherUser: KnownUserConfig by lazy { + val userNumber = (user.ordinal + 1) % KnownUserConfig.values().count() + KnownUserConfig.values()[userNumber] + } + + @After + open fun cleanup() { + with(SalesforceSDKManager.getInstance()) { + userAccountManager.authenticatedUsers.forEach { userAccount -> + logout( + account = userAccountManager.buildAccount(userAccount), + frontActivity = null, + showLoginPage = false, + ) + } + } + } + + open fun loginAndValidate( + knownAppConfig: KnownAppConfig, + scopeSelection: ScopeSelection = EMPTY, + useWebServerFlow: Boolean = true, + useHybridAuthToken: Boolean = true, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + knownUserConfig: KnownUserConfig = user, + ) { + val loginPage = when(knownLoginHostConfig) { + REGULAR_AUTH -> LoginPageObject(composeTestRule) + ADVANCED_AUTH -> ChromeCustomTabPageObject(composeTestRule) + } + + if (!useWebServerFlow || !useHybridAuthToken || + knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY) { + + loginPage.openLoginOptions() + + if (!useWebServerFlow) { + loginOptions.disableWebServerFlow() + } + + if (!useHybridAuthToken) { + loginOptions.disableHybridAuthToken() + } + + if (knownAppConfig == CA_OPAQUE && scopeSelection == EMPTY) { + Espresso.pressBack() + } else { + loginOptions.setOverrideBootConfig(knownAppConfig, scopeSelection) + } + } + + if (knownLoginHostConfig != REGULAR_AUTH) { + loginPage.changeServer(knownLoginHostConfig) + } + + loginPage.login(knownLoginHostConfig, knownUserConfig) + app.waitForAppLoad() + + app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateOAuthValues(knownAppConfig, scopeSelection) + app.validateApiRequest() + } + + fun migrateAndValidate( + knownAppConfig: KnownAppConfig, + knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + scopeSelection: ScopeSelection = EMPTY, + knownUserConfig: KnownUserConfig = user, + ) { + val (preAccessToken, preRefreshToken) = app.getTokens() + app.migrateToNewApp(knownAppConfig, scopeSelection) + val (postAccessToken, postRefreshToken) = app.getTokens() + + // Assert tokens are new + assert(preAccessToken != postAccessToken) + assert(preRefreshToken != postRefreshToken) + + app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateOAuthValues(knownAppConfig, scopeSelection) + + // Assert new tokens work + app.revokeAccessToken() + app.validateApiRequest() + } +} \ No newline at end of file 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 1556b32f24..3e92574bbc 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 @@ -34,6 +34,7 @@ import android.content.IntentFilter import android.content.res.Configuration import android.os.Build import android.os.Bundle +import android.util.Log import android.widget.ScrollView import android.widget.TextView import android.widget.Toast @@ -52,6 +53,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -69,8 +71,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetValue import androidx.compose.material3.Text @@ -126,6 +130,7 @@ import com.salesforce.samples.authflowtester.components.JwtTokenView import com.salesforce.samples.authflowtester.components.OAuthConfigurationView import com.salesforce.samples.authflowtester.components.UserCredentialsView import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -147,16 +152,17 @@ const val REDIRECT_LABEL = "Callback URL" const val SCOPES_LABEL = "Scopes (space-separated)" // For UI Tests -internal const val TITLE_CONTENT_DESC = "app_title" -internal const val REVOKE_BUTTON_CONTENT_DESC = "revoke_button" -internal const val REQUEST_BUTTON_CONTENT_DESC = "request_button" -internal const val CREDS_SECTION_CONTENT_DESC = "user_creds_section" -internal const val JWT_SECTION_CONTENT_DESC = "jwt_section" -internal const val OAUTH_SECTION_CONTENT_DESC = "oauth_config_section" -internal const val MIGRATE_TOKEN_BUTTON_CONTENT_DESC = "migrate_refresh_token_button" -internal const val ALERT_TITLE_CONTENT_DESC = "alert_title" -internal const val ALERT_POSITIVE_BUTTON_CONTENT_DESC = "alert_positive" -internal const val SCROLL_CONTAINER_CONTENT_DESC = "scroll_container" +const val TITLE_CONTENT_DESC = "app_title" +const val REVOKE_BUTTON_CONTENT_DESC = "revoke_button" +const val REQUEST_BUTTON_CONTENT_DESC = "request_button" +const val CREDS_SECTION_CONTENT_DESC = "user_creds_section" +const val JWT_SECTION_CONTENT_DESC = "jwt_section" +const val OAUTH_SECTION_CONTENT_DESC = "oauth_config_section" +const val MIGRATE_TOKEN_BUTTON_CONTENT_DESC = "migrate_refresh_token_button" +const val MIGRATE_USER_RADIO_CONTENT_DESC = "migrate_user_radio" +const val ALERT_TITLE_CONTENT_DESC = "alert_title" +const val ALERT_POSITIVE_BUTTON_CONTENT_DESC = "alert_positive" +const val SCROLL_CONTAINER_CONTENT_DESC = "scroll_container" class AuthFlowTesterActivity : SalesforceActivity() { private var client: RestClient? = null @@ -298,7 +304,12 @@ class AuthFlowTesterActivity : SalesforceActivity() { if (showMigrateBottomSheet) { @Suppress("AssignedValueIsNeverRead") - MigrateAppBottomSheet(onDismiss = { showMigrateBottomSheet = false }) + MigrateAppBottomSheet( + onDismiss = { + currentUser.value = UserAccountManager.getInstance().currentUser + showMigrateBottomSheet = false + } + ) } } @@ -319,9 +330,12 @@ class AuthFlowTesterActivity : SalesforceActivity() { onClick = { coroutineScope.launch { revokeInProgress = true - response = revokeAccessTokenAction(client) - revokeInProgress = false - showAlertDialog = true + val res = revokeAccessTokenAction(client) + withContext(kotlinx.coroutines.Dispatchers.Main) { + response = res + revokeInProgress = false + showAlertDialog = true + } } }, enabled = !revokeInProgress, @@ -402,9 +416,12 @@ class AuthFlowTesterActivity : SalesforceActivity() { coroutineScope.launch { response = null requestInProgress = true - response = makeRestRequest(client, ApiVersionStrings.VERSION_NUMBER) - requestInProgress = false - showAlertDialog = true + val res = makeRestRequest(client, ApiVersionStrings.VERSION_NUMBER) + withContext(kotlinx.coroutines.Dispatchers.Main) { + response = res + requestInProgress = false + showAlertDialog = true + } } }, enabled = !requestInProgress, @@ -508,6 +525,9 @@ class AuthFlowTesterActivity : SalesforceActivity() { var migrationError: String? by remember { mutableStateOf(null) } val clipboard = LocalClipboard.current val context = LocalContext.current + val isPreview = LocalInspectionMode.current + val userAccountManager = if (isPreview) null else UserAccountManager.getInstance() + var selectedUser by remember { mutableStateOf(userAccountManager?.currentUser) } ModalBottomSheet( onDismissRequest = onDismiss, @@ -551,6 +571,40 @@ class AuthFlowTesterActivity : SalesforceActivity() { } } + userAccountManager?.authenticatedUsers?.takeIf { it.size > 1 }?.let { authenticatedUsers -> + authenticatedUsers.forEach { user -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedUser = user } + .padding(horizontal = PADDING.dp, vertical = (PADDING / 2).dp) + .semantics { + contentDescription = MIGRATE_USER_RADIO_CONTENT_DESC + user.username + }, + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selectedUser == user, + onClick = { selectedUser = user }, + ) + Spacer(Modifier.width((PADDING / 2).dp)) + Column { + Text( + text = user.displayName ?: user.username ?: "", + fontWeight = FontWeight.Medium, + ) + user.username?.let { + Text( + text = it, + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + OutlinedTextField( value = consumerKey, onValueChange = {consumerKey = it}, @@ -587,7 +641,8 @@ class AuthFlowTesterActivity : SalesforceActivity() { onClick = { migrationInProgress = true - UserAccountManager.getInstance().migrateRefreshToken( + userAccountManager?.migrateRefreshToken( + userAccount = selectedUser, appConfig = OAuthConfig( consumerKey = consumerKey, redirectUri = callbackUrl, @@ -599,14 +654,16 @@ class AuthFlowTesterActivity : SalesforceActivity() { resources.getString(R.string.migration_success), Toast.LENGTH_LONG, ).show() + onDismiss.invoke() } - onDismiss.invoke() }, onMigrationError = { error, errorDesc, e -> - migrationInProgress = false - migrationError = error + - (errorDesc?.let { " \n\nDesc: $it" } ?: "") + - (e?.let { "\n\nThrowable: $it" } ?: "") + runOnUiThread { + migrationInProgress = false + migrationError = error + + (errorDesc?.let { " \n\nDesc: $it" } ?: "") + + (e?.let { "\n\nThrowable: $it" } ?: "") + } }, ) }, diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt index 10d6f422e8..79d8bc8b4b 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt @@ -44,9 +44,6 @@ class AuthFlowTesterApplication : Application() { with(SalesforceSDKManager.getInstance()) { registerUsedAppFeature(FEATURE_APP_USES_KOTLIN) - - // TODO: remove when W-20524841 is fixed - useHybridAuthentication = false } } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt index 323c0d6b87..0c07a25349 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/components/UserCredentialsView.kt @@ -63,19 +63,19 @@ private const val BEACON = "Beacon" private const val OTHER = "Other" // User Identity fields -internal const val USERNAME = "Username" +const val USERNAME = "Username" private const val USER_ID_LABEL = "User ID" private const val ORGANIZATION_ID = "Organization ID" // OAuth Client Configuration fields -internal const val CLIENT_ID = "Client ID" +const val CLIENT_ID = "Client ID" private const val DOMAIN = "Domain" // Tokens fields -internal const val ACCESS_TOKEN = "Access Token" -internal const val REFRESH_TOKEN = "Refresh Token" -internal const val TOKEN_FORMAT = "Token Format" -internal const val SCOPES = "Scopes" +const val ACCESS_TOKEN = "Access Token" +const val REFRESH_TOKEN = "Refresh Token" +const val TOKEN_FORMAT = "Token Format" +const val SCOPES = "Scopes" // URLs fields private const val INSTANCE_URL = "Instance URL"