diff --git a/kalium b/kalium index 59f008442a6..546cdfe457a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 59f008442a67f9026fd66e410da35835819874d8 +Subproject commit 546cdfe457ad8953ad7497bbfcd0cbbb645150b3 diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt index 4919a0c348c..8dc51a32b38 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt @@ -99,19 +99,19 @@ class AccountManagement : KoinTest { assertEmailWelcomePage() } pages.loginPage.apply { - enterPersonalUserLoggingEmail(teamMember.email ?: "") + enterTeamMemberLoggingEmail(teamMember.email ?: "") clickLoginButton() - enterPersonalUserLoginPassword(teamMember.password ?: "") + enterTeamMemberLoggingPassword(teamMember.password ?: "") clickLoginButton() } pages.registrationPage.apply { - waitUntilLoginFlowIsComplete() + waitUntilLoginFlowIsCompleted() clickAllowNotificationButton() clickDeclineShareDataAlert() } pages.conversationListPage.apply { assertGroupConversationVisible("MyTeam") - clickMainMenuButtonOnConversationPage() + clickConversationsMenuEntry() clickSettingsButtonOnMenuEntry() pages.settingsPage.apply { clickDebugSettingsButton() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharing.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharing.kt index c7f7c052a73..2fa53bf35c1 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharing.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharing.kt @@ -29,7 +29,7 @@ import backendUtils.team.deleteTeam import com.wire.android.tests.support.UiAutomatorSetup import com.wire.android.tests.core.di.testModule import com.wire.android.tests.core.pages.AllPages -import deleteDownloadedFilesContainingFileWord +import deleteDownloadedFilesContaining import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -66,7 +66,7 @@ class FileSharing : KoinTest { context = InstrumentationRegistry.getInstrumentation().context // device = UiAutomatorSetup.start(UiAutomatorSetup.APP_DEV) device = UiAutomatorSetup.start(UiAutomatorSetup.APP_INTERNAL) - // device = UiAutomatorSetup.start(UiAutomatorSetup.APP_STAGING) + // device = UiAutomatorSetup.start(UiAutomatorSetup.APP_STAGING) backendClient = BackendClient.loadBackend("STAGING") teamHelper = TeamHelper() } @@ -75,9 +75,9 @@ class FileSharing : KoinTest { fun tearDown() { // UiAutomatorSetup.stopApp() // To delete team - teamOwner2?.deleteTeam(backendClient!!) + teamOwner2?.deleteTeam(backendClient!!) teamOwner1?.deleteTeam(backendClient!!) - deleteDownloadedFilesContainingFileWord() + deleteDownloadedFilesContaining("File") } @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -145,7 +145,7 @@ class FileSharing : KoinTest { } pages.registrationPage.apply { - waitUntilLoginFlowIsComplete() + waitUntilLoginFlowIsCompleted() clickAllowNotificationButton() clickDeclineShareDataAlert() testServiceHelper.apply { diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt index b01325843af..aba49e1796e 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt @@ -118,7 +118,7 @@ class GroupMessaging : KoinTest { clickLoginButton() } pages.registrationPage.apply { - waitUntilLoginFlowIsComplete() + waitUntilLoginFlowIsCompleted() clickAllowNotificationButton() clickDeclineShareDataAlert() } @@ -139,7 +139,7 @@ class GroupMessaging : KoinTest { typeMessageInInputField("Hello Team Members") clickSendButton() assertSentMessageIsVisibleInCurrentConversation("Hello Team Members") - tapBackButtonOnConversationViewPage() + tapBackButtonToCloseConversationViewPage() } testServiceHelper.apply { addDevice("user2Name", null, "Device1") diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt index 434fbedf644..93bb215cbac 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt @@ -122,13 +122,13 @@ class NewMemberMessaging : KoinTest { clickProceedButtonOnDeeplinkOverlay() } pages.loginPage.apply { - enterTeamOwnerLoggingEmail(member1?.email ?: "") + enterTeamMemberLoggingEmail(member1?.email ?: "") clickLoginButton() - enterTeamOwnerLoggingPassword(member1?.password ?: "") + enterTeamMemberLoggingPassword(member1?.password ?: "") clickLoginButton() } pages.registrationPage.apply { - waitUntilLoginFlowIsComplete() + waitUntilLoginFlowIsCompleted() clickAllowNotificationButton() clickDeclineShareDataAlert() } @@ -152,7 +152,7 @@ class NewMemberMessaging : KoinTest { typeMessageInInputField("Hello Team Owner") clickSendButton() assertSentMessageIsVisibleInCurrentConversation("Hello Team Owner") - tapBackButtonOnConversationViewPage() + tapBackButtonToCloseConversationViewPage() } pages.connectedUserProfilePage.apply { tapCloseButtonOnConnectedUserProfilePage() @@ -182,7 +182,7 @@ class NewMemberMessaging : KoinTest { } pages.conversationViewPage.apply { - tapBackButtonOnConversationViewPage() + tapBackButtonToCloseConversationViewPage() } pages.conversationListPage.apply { diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt index ccdf580f6d8..9b1bf816da5 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt @@ -135,7 +135,7 @@ class PersonalAccountLifeCycle : KoinTest { assertUserNameHelpText() setUserName(personalUser?.uniqueUsername.orEmpty()) clickConfirmButton() - waitUntilRegistrationFlowIsComplete() + waitUntilRegistrationFlowIsCompleted() clickAllowNotificationButton() clickDeclineShareDataAlert() assertConversationPageVisible() @@ -200,10 +200,10 @@ class PersonalAccountLifeCycle : KoinTest { assertUnblockUserButtonVisible() tapCloseButtonOnConnectedUserProfilePage() pages.conversationViewPage.apply { - tapBackButtonOnConversationViewPage() + tapBackButtonToCloseConversationViewPage() } pages.conversationListPage.apply { - clickMainMenuButtonOnConversationPage() + clickConversationsMenuEntry() clickSettingsButtonOnMenuEntry() } waitFor(1) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt new file mode 100644 index 00000000000..06ee9b28ae4 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt @@ -0,0 +1,244 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.core.criticalFlows + +import SSOServiceHelper.thereIsASSOTeamOwnerForOkta +import SSOServiceHelper.userAddsOktaUser +import SSOServiceHelper.userXIsMe +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import backendUtils.BackendClient +import backendUtils.team.TeamHelper +import backendUtils.team.deleteTeam +import com.wire.android.tests.core.di.testModule +import com.wire.android.tests.core.pages.AllPages +import com.wire.android.tests.support.UiAutomatorSetup +import deleteDownloadedFilesContaining +import kotlinx.coroutines.runBlocking +import okta.OktaApiClient +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.inject +import service.TestServiceHelper +import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import user.usermanager.ClientUserManager +import user.utils.ClientUser +import kotlin.getValue + +@RunWith(AndroidJUnit4::class) +class SSODeviceBackup : KoinTest { + + @get:Rule + val koinTestRule = KoinTestRule.Companion.create { + modules(testModule) + } + private val pages: AllPages by inject() + private lateinit var device: UiDevice + + lateinit var context: Context + var teamOwner: ClientUser? = null + var member1: ClientUser? = null + private lateinit var oktaApiClient: OktaApiClient + + var backendClient: BackendClient? = null + val teamServiceHelper by lazy { + TestServiceHelper() + } + val teamHelper by lazy { + TeamHelper() + } + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + device = UiAutomatorSetup.start(UiAutomatorSetup.APP_INTERNAL) + backendClient = BackendClient.loadBackend("STAGING") + oktaApiClient = OktaApiClient() + } + + @After + fun tearDown() { + teamOwner?.deleteTeam(backendClient!!) + deleteDownloadedFilesContaining("Wire") + oktaApiClient.cleanUp() + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + @Test + fun givenSSOTeamWithOkta_whenSettingUpNewDeviceAndRestoringBackup_thenMessageIsRestored() { + + runBlocking { + + teamServiceHelper.thereIsASSOTeamOwnerForOkta( + context, + "user1Name", + "Messaging", + oktaApiClient + ) + + teamServiceHelper.userAddsOktaUser("user1Name", "user2Name", oktaApiClient) + + teamServiceHelper.userXIsMe("user2Name") + + teamOwner = teamHelper?.usersManager!!.findUserBy( + "user1Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + member1 = teamHelper?.usersManager!!.findUserBy( + "user2Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + + val ssoCode = SSOServiceHelper.getSSOCode() + + pages.registrationPage.apply { + assertEmailWelcomePage() + } + pages.loginPage.apply { + clickStagingDeepLink() + clickProceedButtonOnDeeplinkOverlay() + } + pages.loginPage.apply { + enterSSOCodeOnSSOLoginTab(ssoCode) + clickLoginButton() + } + pages.chromePage.apply { + } + pages.ssoPage.apply { + enterOktaEmail(member1?.email ?: "") + enterOktaPassword(member1?.password ?: "") + waitFor(20) // Delay added to allow Okta app assignment to fully sync and avoid 403 error + tapOktaSignIn() + } + pages.registrationPage.apply { + clickAllowNotificationButton() + setUserName(member1?.uniqueUsername.orEmpty()) + clickConfirmButton() + waitUntilLoginFlowIsCompleted() + clickDeclineShareDataAlert() + } + pages.conversationListPage.apply { + tapStartNewConversationButton() + } + pages.searchPage.apply { + tapSearchPeopleField() + typeUserNameInSearchField("user1Name") + assertUsernameInSearchResultIs(teamOwner?.name ?: "") + tapUsernameInSearchResult(teamOwner?.name ?: "") + } + pages.connectedUserProfilePage.apply { + assertStartConversationButtonVisible() + clickStartConversationButton() + } + pages.conversationViewPage.apply { + assertConversationScreenVisible() + typeMessageInInputField("Testing of the backup functionality") + clickSendButton() + assertSentMessageIsVisibleInCurrentConversation("Testing of the backup functionality") + tapBackButtonToCloseConversationViewPage() + } + pages.connectedUserProfilePage.apply { + tapCloseButtonOnConnectedUserProfilePage() + } + pages.conversationListPage.apply { + clickCloseButtonOnNewConversationScreen() + assertConversationListVisible() + } + pages.conversationListPage.apply { + clickConversationsMenuEntry() + clickSettingsButtonOnMenuEntry() + } + pages.settingsPage.apply { + openBackupAndRestoreConversationsMenu() + iSeeBackupPageHeading() + clickCreateBackupButton() + clickBackUpNowButton() + iSeeBackupConfirmation("Conversations successfully saved") + iTapSaveFileButton() + iTapSaveInOSMenuButton() + iSeeBackupPageHeading() + clickBackButtonOnSettingsPage() + } + pages.conversationListPage.apply { + clickConversationsMenuEntry() + clickConversationsButtonOnMenuEntry() + clickUserProfileButton() + } + pages.selfUserProfilePage.apply { + iSeeUserProfilePage() + tapLogoutButton() + iSeeClearDataOnLogOutAlert() + iSeeInfoTextCheckbox("Delete all your personal information and conversations on this device") + tapInfoTextCheckbox() + tapLogoutButton() + } + pages.registrationPage.apply { + assertEmailWelcomePage() + } + pages.loginPage.apply { + clickStagingDeepLink() + clickProceedButtonOnDeeplinkOverlay() + } + pages.loginPage.apply { + enterSSOCodeOnSSOLoginTab(ssoCode) + clickLoginButton() + } + pages.registrationPage.apply { + waitUntilLoginFlowIsCompleted() + clickDeclineShareDataAlert() + } + pages.conversationListPage.apply { + assertConversationIsVisibleWithTeamOwner(teamOwner?.name ?: "") + tapConversationNameInConversationList(teamOwner?.name ?: "") + } + pages.conversationViewPage.apply { + assertMessageNotVisible("Testing of the backup functionality") + tapBackButtonToCloseConversationViewPage() + } + pages.conversationListPage.apply { + + clickConversationsMenuEntry() + clickSettingsButtonOnMenuEntry() + } + pages.settingsPage.apply { + openBackupAndRestoreConversationsMenu() + iSeeBackupPageHeading() + clickRestoreBackupButton() + clickChooseBackupFileButton() + selectBackupFileInDocumentsUI(teamHelper, "user2Name") + waitUntilThisTextIsDisplayedOnBackupAlert("Conversations have been restored") + clickOkButtonOnBackupAlert() + } + pages.conversationListPage.apply { + assertConversationListVisible() + assertConversationIsVisibleWithTeamOwner(teamOwner?.name ?: "") + tapConversationNameInConversationList(teamOwner?.name ?: "") + } + pages.conversationViewPage.apply { + assertMessageNotVisible("Testing of the backup functionality") + } + } + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt index 895eed5743a..b35eb96439c 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt @@ -36,4 +36,10 @@ class AllPages(val device: UiDevice) { val groupConversationDetailsPage = GroupConversationDetailsPage(device) val searchPage = SearchPage(device) + + val ssoPage = SSOPage(device) + + val chromePage = ChromePage(device) + + val selfUserProfilePage = SelfUserProfilePage(device) } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ChromePage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ChromePage.kt new file mode 100644 index 00000000000..46fc7dcb16c --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ChromePage.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.core.pages + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import uiautomatorutils.UiSelectorParams +import uiautomatorutils.UiWaitUtils + +data class ChromePage(private val device: UiDevice) { + + private val useWithoutAccountLocator = UiSelectorParams(text = "Use without an account") + + fun clickUseWithoutAccount(): ChromePage { + UiWaitUtils.waitElement(useWithoutAccountLocator).click() + return this + } + + fun clearChromeBrowserCache(): ChromePage { + val serial = runShellCommand("getprop ro.boot.serialno").trim() + + when (serial) { + "ce091829205f7a3704" -> { + runShellCommand("pm clear org.lineageos.jelly") + } + + "25181JEGR05249" -> { + runShellCommand("pm clear app.vanadium.browser") + } + + else -> { + runShellCommand("pm clear com.android.chrome") + } + } + return this + } + + private fun runShellCommand(command: String): String = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + .executeShellCommand(command) + .trim() +} + +fun dismissChromeOnboardingIfVisible(device: UiDevice) { + val noThanks = device.findObject(By.text("No, thanks")) + if (noThanks != null) { + noThanks.click() + device.waitForIdle() + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt index af4ab8dc14b..aeee4a71303 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt @@ -28,12 +28,17 @@ import kotlin.test.DefaultAsserter.assertTrue data class ConversationListPage(private val device: UiDevice) { private val searchField = UiSelectorParams(description = "Search conversations") + private val userProfileButtonNoPhoto = UiSelectorParams(description = "Your profile") + private val userProfileButton = UiSelectorParams(resourceId = "User avatar") private val conversationListHeading = UiSelectorParams( textContains = "Conversations" ) private val mainMenuButton = UiSelectorParams(description = "Main navigation") private val settingsButton = UiSelectorParams(text = "Settings") + + private val conversationsButton = UiSelectorParams(text = "Conversations") + private fun displayedUserName(userName: String) = UiSelectorParams(text = userName) private val conversationNameSelector: (String) -> UiSelectorParams = { conversationName -> UiSelectorParams(text = conversationName) @@ -59,7 +64,7 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun clickMainMenuButtonOnConversationPage(): ConversationListPage { + fun clickConversationsMenuEntry(): ConversationListPage { UiWaitUtils.waitElement(mainMenuButton).click() return this } @@ -69,6 +74,11 @@ data class ConversationListPage(private val device: UiDevice) { return this } + fun clickConversationsButtonOnMenuEntry(): ConversationListPage { + UiWaitUtils.waitElement(conversationsButton).click() + return this + } + fun assertGroupConversationVisible(conversationName: String): ConversationListPage { val conversation = UiWaitUtils.waitElement(UiSelectorParams(text = conversationName)) assertTrue("Conversation '$conversationName' is not visible", !conversation.visibleBounds.isEmpty) @@ -124,7 +134,7 @@ data class ConversationListPage(private val device: UiDevice) { fun assertConversationNotVisible(conversationName: String): ConversationListPage { val conversation = findElementOrNull(conversationNameSelector(conversationName)) Assert.assertTrue( - "❌ Conversation '$conversationName' is still visible.", + "Conversation '$conversationName' is still visible.", conversation == null || conversation.visibleBounds.isEmpty ) return this @@ -183,4 +193,24 @@ data class ConversationListPage(private val device: UiDevice) { userName.click() return this } + + fun clickUserProfileButton(): ConversationListPage { + val buttonWithPhoto = UiWaitUtils.findElementOrNull(userProfileButton) + if (buttonWithPhoto != null && !buttonWithPhoto.visibleBounds.isEmpty) { + buttonWithPhoto.click() + } else { + val buttonNoPhoto = UiWaitUtils.waitElement(userProfileButtonNoPhoto) + buttonNoPhoto.click() + } + return this + } + + fun assertConversationIsVisibleWithTeamOwner(userName: String): ConversationListPage { + try { + UiWaitUtils.waitElement(displayedUserName(userName)) + } catch (e: AssertionError) { + throw AssertionError("Team owner name '$userName' is not visible in conversation view", e) + } + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index 953f69a2ab4..2f33973a16c 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt @@ -80,11 +80,20 @@ data class ConversationViewPage(private val device: UiDevice) { } fun assertConversationIsVisibleWithTeamMember(userName: String): ConversationViewPage { - val teamMemberName = UiWaitUtils.waitElement(displayedUserName(userName)) - assertTrue( - "Team member name '$userName' is not visible in conversation view", - !teamMemberName.visibleBounds.isEmpty - ) + try { + UiWaitUtils.waitElement(displayedUserName(userName)) + } catch (e: AssertionError) { + throw AssertionError("Team member name '$userName' is not visible in conversation view", e) + } + return this + } + + fun assertConversationIsVisibleWithTeamOwner(userName: String): ConversationViewPage { + try { + UiWaitUtils.waitElement(displayedUserName(userName)) + } catch (e: AssertionError) { + throw AssertionError("Team owner name '$userName' is not visible in conversation view", e) + } return this } @@ -268,6 +277,7 @@ data class ConversationViewPage(private val device: UiDevice) { currentPackage.contains("APP_") ) } + fun typeMessageInInputField(message: String): ConversationViewPage { UiWaitUtils.waitElement(messageInputField).click() device.type(message) @@ -299,7 +309,7 @@ data class ConversationViewPage(private val device: UiDevice) { ) } - fun tapBackButtonOnConversationViewPage(): ConversationViewPage { + fun tapBackButtonToCloseConversationViewPage(): ConversationViewPage { UiWaitUtils.waitElement(backButton).click() return this } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/LoginPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/LoginPage.kt index 197e5185602..d59c95479de 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/LoginPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/LoginPage.kt @@ -56,6 +56,13 @@ data class LoginPage(private val device: UiDevice) { return this } + fun enterTeamMemberLoggingPassword(password: String): LoginPage { + val passwordInputField = UiWaitUtils.waitElement(passwordInputFieldSelector) + passwordInputField.click() + passwordInputField.text = password + return this + } + fun enterTeamOwnerLoggingEmail(email: String): LoginPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) // Click the input field (waits until visible) @@ -68,6 +75,30 @@ data class LoginPage(private val device: UiDevice) { return this } + fun enterTeamMemberLoggingEmail(email: String): LoginPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // Click the input field (waits until visible) + device.findObject(emailInputField).click() + // Wait again to avoid stale object + device.findObject(emailInputField) + // Set text via UiObject (more reliable than UiObject2.text=) + device.findObject(emailInputField).setText(email) + + return this + } + + fun enterSSOCodeOnSSOLoginTab(email: String): LoginPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // Click the input field (waits until visible) + device.findObject(emailInputField).click() + // Wait again to avoid stale object + device.findObject(emailInputField) + // Set text via UiObject (more reliable than UiObject2.text=) + device.findObject(emailInputField).setText(email) + + return this + } + fun enterTeamOwnerLoggingPassword(password: String): LoginPage { val passwordInputField = UiWaitUtils.waitElement(passwordInputFieldSelector) passwordInputField.click() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt index 464ec12b577..f4772990031 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt @@ -208,14 +208,14 @@ class RegistrationPage(private val device: UiDevice) { return this } - fun waitUntilLoginFlowIsComplete(): RegistrationPage { + fun waitUntilLoginFlowIsCompleted(): RegistrationPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) waitUntilElementGone(device, loginButtonGoneSelector, timeoutMillis = 12_000) waitUntilElementGone(device, settingUpWireGoneSelector, timeoutMillis = 30_000) return this } - fun waitUntilRegistrationFlowIsComplete(): RegistrationPage { + fun waitUntilRegistrationFlowIsCompleted(): RegistrationPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 14_000) return this diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt new file mode 100644 index 00000000000..8364c20bf46 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.core.pages + +import androidx.test.uiautomator.UiDevice +import uiautomatorutils.UiSelectorParams +import uiautomatorutils.UiWaitUtils + +data class SSOPage(private val device: UiDevice) { + + private val oktaUsernameField = UiSelectorParams(resourceId = "okta-signin-username") + private val oktaPasswordField = UiSelectorParams(resourceId = "okta-signin-password") + private val oktaSignInButton = UiSelectorParams(resourceId = "okta-signin-submit") + + fun enterOktaEmail(email: String): SSOPage { + val usernameField = UiWaitUtils.waitElement(oktaUsernameField) + usernameField.text = email + return this + } + + fun enterOktaPassword(password: String): SSOPage { + val passwordField = UiWaitUtils.waitElement(oktaPasswordField) + passwordField.text = password + return this + } + + fun tapOktaSignIn(): SSOPage { + val signInBtn = UiWaitUtils.waitElement(oktaSignInButton) + signInBtn.click() + return this + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt index 56e1bbaf4bb..78cb671365a 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt @@ -64,4 +64,18 @@ data class SearchPage(private val device: UiDevice) { device.type(toType) return this } + + fun typeUserNameInSearchField(alias: String): SearchPage { + val teamHelper by lazy { + TeamHelper() + } + // Resolve the alias to the username + val userName = teamHelper.usersManager.replaceAliasesOccurrences( + alias, + ClientUserManager.FindBy.NAME_ALIAS + ) + UiWaitUtils.waitElement(searchFieldSearchPeople).click() + device.type(userName) + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SelfUserProfilePage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SelfUserProfilePage.kt new file mode 100644 index 00000000000..06c2e30a097 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SelfUserProfilePage.kt @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.core.pages + +import androidx.test.uiautomator.UiDevice +import uiautomatorutils.UiSelectorParams +import uiautomatorutils.UiWaitUtils + +data class SelfUserProfilePage(private val device: UiDevice) { + + private val userProfilePageTitle = UiSelectorParams(text = "User Profile") + private val logoutButton = UiSelectorParams(text = "Log out") + private val clearDataAlert = UiSelectorParams(text = "Clear Data?") + + private val infoTextCheckbox = UiSelectorParams(className = "android.widget.CheckBox") + + fun iSeeUserProfilePage(): SelfUserProfilePage { + try { + UiWaitUtils.waitElement(userProfilePageTitle) + } catch (e: AssertionError) { + throw AssertionError("User Profile Page is not displayed", e) + } + return this + } + + fun tapLogoutButton(): SelfUserProfilePage { + UiWaitUtils.waitElement(logoutButton).click() + return this + } + + fun iSeeClearDataOnLogOutAlert(): SelfUserProfilePage { + try { + UiWaitUtils.waitElement(clearDataAlert) + } catch (e: AssertionError) { + throw AssertionError("Clear Data alert is not visible", e) + } + return this + } + + fun iSeeInfoTextCheckbox(message: String): SelfUserProfilePage { + val messageSelector = UiSelectorParams(text = message) + try { + UiWaitUtils.waitElement(messageSelector) + } catch (e: AssertionError) { + throw AssertionError("Message '$message' is not visible on the Clear Data alert", e) + } + return this + } + + fun tapInfoTextCheckbox(): SelfUserProfilePage { + UiWaitUtils.waitElement(infoTextCheckbox).click() + return this + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt index 2b9a55bc265..1a27de256f9 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt @@ -25,16 +25,31 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until +import backendUtils.team.TeamHelper import junit.framework.TestCase.assertFalse import org.hamcrest.CoreMatchers.`is` import org.junit.Assert import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils +import user.usermanager.ClientUserManager import kotlin.test.DefaultAsserter.assertTrue data class SettingsPage(private val device: UiDevice) { - + private fun backupFileLocator(uniqueUserName: String) = UiSelectorParams(textContains = "Wire-$uniqueUserName") private val privacySettingsButton = UiSelectorParams(text = "Privacy Settings") + private val backUpMenuButton = UiSelectorParams(text = "Back up & Restore Conversations") + private val backupPageHeading = UiSelectorParams(text = "Back up & Restore Conversations") + private val restoreBackupButton = UiSelectorParams(text = "Restore from Backup") + + private val createBackupButton = UiSelectorParams(text = "Create a Backup") + private val backUpNowButton = UiSelectorParams(text = "Back Up Now") + + private val saveFileButton = UiSelectorParams(text = "Save File") + + private val okButton = UiSelectorParams(text = "OK") + private val saveButtonOSMenu = UiSelectorParams(text = "SAVE") + + private val chooseBackupButton = UiSelectorParams(text = "Choose Backup File") private val debugSettingsButton = UiSelectorParams(text = "Debug Settings") private val analyticsInitializedLabel = UiSelectorParams(text = "Analytics Initialized") private val enableLoggingText = UiSelector().text("Enable Logging") @@ -46,7 +61,6 @@ data class SettingsPage(private val device: UiDevice) { private val toggle = UiSelector().className("android.view.View") private val analyticsTrackingLabel = UiSelector().text("Analytics Tracking Identifier") private val anonymousUsageDataText = UiSelector().text("Send anonymous usage data") - private val setAppLockInfoText = UiSelectorParams( textContains = "The app will lock itself after 1 minute of inactivity" ) @@ -118,6 +132,60 @@ data class SettingsPage(private val device: UiDevice) { return this } + fun iSeeBackupPageHeading(): SettingsPage { + try { + UiWaitUtils.waitElement(backupPageHeading) + } catch (e: AssertionError) { + throw AssertionError("Backup Page is not displayed", e) + } + return this + } + + fun openBackupAndRestoreConversationsMenu(): SettingsPage { + UiWaitUtils.waitElement(backUpMenuButton).click() + return this + } + + fun clickRestoreBackupButton(): SettingsPage { + UiWaitUtils.waitElement(restoreBackupButton).click() + return this + } + + fun clickCreateBackupButton(): SettingsPage { + UiWaitUtils.waitElement(createBackupButton).click() + return this + } + + fun clickBackUpNowButton(): SettingsPage { + UiWaitUtils.waitElement(backUpNowButton).click() + return this + } + + fun iTapSaveFileButton(): SettingsPage { + UiWaitUtils.waitElement(saveFileButton).click() + return this + } + + fun iTapSaveInOSMenuButton(): SettingsPage { + UiWaitUtils.waitElement(saveButtonOSMenu).click() + return this + } + + fun iSeeBackupConfirmation(text: String): SettingsPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val backupConfirmed = device.wait( + Until.findObject(By.textContains(text)), + 5_000 + ) + assertTrue("Expected message '$text' was not displayed", backupConfirmed != null) + return this + } + + fun clickChooseBackupFileButton(): SettingsPage { + UiWaitUtils.waitElement(chooseBackupButton).click() + return this + } + fun clickDebugSettingsButton(): SettingsPage { UiWaitUtils.waitElement(debugSettingsButton).click() return this @@ -354,4 +422,36 @@ data class SettingsPage(private val device: UiDevice) { } return this } + + fun selectBackupFileInDocumentsUI(teamHelper: TeamHelper, userAlias: String): SettingsPage { + val user = teamHelper.usersManager.findUserBy( + userAlias, + ClientUserManager.FindBy.NAME_ALIAS + ) + val uniqueUserName = user?.uniqueUsername.orEmpty() + try { + UiWaitUtils.waitElement(backupFileLocator(uniqueUserName)).click() + } catch (e: AssertionError) { + throw AssertionError( + "Backup file with name 'Wire-$uniqueUserName' not found in DocumentsUI", + e + ) + } + return this + } + + fun waitUntilThisTextIsDisplayedOnBackupAlert(text: String): SettingsPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val text = device.findObject(UiSelector().text(text)) + + if (!text.waitForExists(5_000)) { + throw AssertionError("Text '$text' was not displayed on the backup alert within timeout") + } + return this + } + + fun clickOkButtonOnBackupAlert(): SettingsPage { + UiWaitUtils.waitElement(okButton).click() + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/ApplockTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/ApplockTest.kt index b5f7477d382..66711c90d03 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/ApplockTest.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/ApplockTest.kt @@ -96,7 +96,7 @@ class ApplockTest : KoinTest { clickLoginButton() } pages.registrationPage.apply { - waitUntilLoginFlowIsComplete() + waitUntilLoginFlowIsCompleted() clickAllowNotificationButton() clickAgreeShareDataAlert() assertConversationPageVisible() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/GdprTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/GdprTest.kt index c8287acf993..5aec7feecf6 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/GdprTest.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/GdprTest.kt @@ -86,7 +86,7 @@ class GdprTest : KoinTest { clickLoginButton() } pages.registrationPage.apply { - waitUntilLoginFlowIsComplete() + waitUntilLoginFlowIsCompleted() clickAllowNotificationButton() setUserName(registeredUser?.uniqueUsername ?: "") } @@ -96,7 +96,7 @@ class GdprTest : KoinTest { assertConversationPageVisible() } pages.conversationListPage.apply { - clickMainMenuButtonOnConversationPage() + clickConversationsMenuEntry() clickSettingsButtonOnMenuEntry() } pages.settingsPage.apply { diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt index 6d2df4234db..973f07c2bee 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt @@ -94,7 +94,7 @@ class PersonalUserRegistrationTest : KoinTest { assertUserNameHelpText() setUserName(userInfo.username) clickConfirmButton() - waitUntilRegistrationFlowIsComplete() + waitUntilRegistrationFlowIsCompleted() clickAllowNotificationButton() clickDeclineShareDataAlert() assertConversationPageVisible() diff --git a/tests/testsSupport/build.gradle.kts b/tests/testsSupport/build.gradle.kts index a1628a27019..a2a8c48ffb9 100644 --- a/tests/testsSupport/build.gradle.kts +++ b/tests/testsSupport/build.gradle.kts @@ -18,7 +18,7 @@ val secretsJson = rootProject.file("secrets.json") // Function to sanitize keys by replacing spaces and dashes with underscores, and making uppercase fun sanitize(text: String): String { - return text.replace("[-\\s]+".toRegex(), "_").uppercase() + return text.replace("[.\\-\\s]+".toRegex(), "_").uppercase() } // Function to escape special characters for BuildConfig string fields @@ -116,14 +116,17 @@ tasks.register("fetchSecrets") { // 3. Convert fields from List to Map where label is the key (simplify structure) val rawFields = itemData["fields"] as? List> ?: emptyList() + val fieldsMap = mutableMapOf>() + rawFields.forEachIndexed { index, field -> + val label = field["label"] as? String ?: return@forEachIndexed + // If label already exists, append index to make it unique + val uniqueLabel = if (fieldsMap.containsKey(label)) "${label}_$index" else label - val fieldsMap = rawFields.mapNotNull { field -> - val label = field["label"] as? String ?: return@mapNotNull null - label to mapOf( + fieldsMap[uniqueLabel] = mapOf( "type" to field["type"], "value" to field["value"] ) - }.toMap() + } // Replace original fields list with simplified map val simplifiedItemData = itemData.toMutableMap() diff --git a/tests/testsSupport/src/main/backendUtils/BackendClient.kt b/tests/testsSupport/src/main/backendUtils/BackendClient.kt index 43da72cecc9..dd6a0721980 100644 --- a/tests/testsSupport/src/main/backendUtils/BackendClient.kt +++ b/tests/testsSupport/src/main/backendUtils/BackendClient.kt @@ -21,10 +21,12 @@ package backendUtils import CredentialsManager import android.net.Uri +import backendUtils.team.TeamRole import backendUtils.team.defaultheaders import backendUtils.team.getAuthToken import backendUtils.team.getTeamId import com.wire.android.testSupport.BuildConfig +import com.wire.android.testSupport.backendConnections.team.Team import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import logger.WireTestLogger @@ -34,6 +36,7 @@ import network.NumberSequence import network.RequestOptions import org.json.JSONObject import service.models.Connection +import service.models.TeamMember import user.utils.AccessCookie import user.utils.AccessCredentials import user.utils.AccessToken @@ -85,6 +88,7 @@ class BackendClient( companion object { const val contentType = "Content-Type" + const val accept = "Accept" const val applicationJson = "application/json" const val AUTHORIZATION = "Authorization" @@ -138,7 +142,7 @@ class BackendClient( } fun getDefault(): BackendClient? { - return loadBackend("Staging") + return loadBackend("STAGING") } fun loadBackend(connectionName: String): BackendClient { @@ -366,6 +370,97 @@ class BackendClient( return "Email Registered" } + fun createIdentityProvider(user: ClientUser, metadata: String): String { + val token = runBlocking { getAuthToken(user) } + val url = URL("identity-providers".composeCompleteUrl()) + + val headers = defaultheaders.toMutableMap().apply { + put("Authorization", "${token?.type} ${token?.value}") + put("Accept", applicationJson) + put("Content-Type", "application/xml") + } + + val response = NetworkBackendClient.sendJsonRequestWithCookies( + url = url, + method = "POST", + body = metadata, + headers = headers, + options = RequestOptions( + accessToken = token, + expectedResponseCodes = NumberSequence.Array(intArrayOf(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_CREATED)) + ) + ) + + val responseBody = JSONObject(response.body) + return responseBody.getString("id") + } + + fun getAllTeams(forUser: ClientUser): List { + val token = runBlocking { getAuthToken(forUser) } + val url = URL("teams".composeCompleteUrl()) + + val headers = defaultheaders.toMutableMap().apply { + put("Authorization", "${token?.type} ${token?.value}") + put("Accept", applicationJson) + } + + val response = NetworkBackendClient.sendJsonRequestWithCookies( + url = url, + method = "GET", + headers = headers, + options = RequestOptions( + accessToken = token, + expectedResponseCodes = NumberSequence.Array(intArrayOf(HttpURLConnection.HTTP_OK)) + ) + ) + + val jsonResponse = JSONObject(response.body) + val teams = jsonResponse.getJSONArray("teams") + + return buildList { + for (i in 0 until teams.length()) { + add(Team.fromJSON(teams.getJSONObject(i))) + } + } + } + + fun getTeamMembers(asUser: ClientUser): List { + val firstTeam = getAllTeams(asUser).first() + return getTeamMembers(runBlocking { getAuthToken(asUser)!! }, firstTeam.id) + } + + private fun getTeamMembers(token: AccessToken, teamId: String): List { + val url = URL("teams/$teamId/members".composeCompleteUrl()) + + val headers = defaultheaders.toMutableMap().apply { + put("Authorization", "${token.type} ${token.value}") + put("Accept", applicationJson) + } + + val response = NetworkBackendClient.sendJsonRequestWithCookies( + url = url, + method = "GET", + headers = headers, + options = RequestOptions( + accessToken = token, + expectedResponseCodes = NumberSequence.Array(intArrayOf(HttpURLConnection.HTTP_OK)) + ) + ) + + val jsonResponse = JSONObject(response.body) + val members = jsonResponse.getJSONArray("members") + + return buildList { + for (i in 0 until members.length()) { + val member = members.getJSONObject(i) + val userId = member.getString("user") + val permissions = member.getJSONObject("permissions") + val role = TeamRole.getByPermissionBitMask(permissions.getInt("self")) + add(TeamMember(userId, role)) + } + } + } + private fun getFeatureConfig(feature: String, user: ClientUser): JSONObject { val token = runBlocking { getAuthToken(user) diff --git a/tests/testsSupport/src/main/backendUtils/team/TeamsExtension.kt b/tests/testsSupport/src/main/backendUtils/team/TeamsExtension.kt index 6f0a8fb82ce..c5652fd4c6a 100644 --- a/tests/testsSupport/src/main/backendUtils/team/TeamsExtension.kt +++ b/tests/testsSupport/src/main/backendUtils/team/TeamsExtension.kt @@ -545,3 +545,23 @@ enum class TeamRoles(val role: String) { Partner("Partner"), External("External") } + +enum class TeamRole(val permissionBitMask: Int) { + OWNER(8191), + ADMIN(5951), + MEMBER(1587), + INVALID(1234), + PARTNER(1025); + + companion object { + fun getByPermissionBitMask(permissionBitMask: Int): TeamRole = + entries.firstOrNull { it.permissionBitMask == permissionBitMask } + ?: throw NoSuchElementException("Permission bit mask '$permissionBitMask' is unknown") + + fun getByName(roleName: String): TeamRole = + entries.firstOrNull { it.name.equals(roleName, ignoreCase = true) } + ?: throw NoSuchElementException("Team role '$roleName' is unknown") + } + + override fun toString(): String = name +} diff --git a/tests/testsSupport/src/main/okta/OktaApiClient.kt b/tests/testsSupport/src/main/okta/OktaApiClient.kt new file mode 100644 index 00000000000..51fa3fabca5 --- /dev/null +++ b/tests/testsSupport/src/main/okta/OktaApiClient.kt @@ -0,0 +1,311 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +@file:Suppress("TooGenericExceptionCaught", "VariableNaming", "MagicNumber", "PackageNaming", "TooGenericExceptionThrown") + +package okta + +import android.content.Context +import com.wire.android.testSupport.BuildConfig +import com.wire.android.testSupport.R +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.net.HttpURLConnection +import java.net.URL +import javax.net.ssl.HttpsURLConnection +import kotlin.math.max + +class OktaApiClient { + + private val CONNECT_TIMEOUT_MS = 3_000 + private val READ_TIMEOUT_MS = 240_000 + private val BASE_URI = "https://dev-500508-admin.oktapreview.com" + private val apiKey: String by lazy { BuildConfig.OKTA_API_KEY_PASSWORD } + + private var applicationId: String? = null + private val userIds = LinkedHashSet() + + companion object { + @JvmStatic + fun getFinalizeUrlDependingOnBackend(backendUrl: String): String { + val trimmed = backendUrl.removeSuffix("/") + return "$trimmed/sso/finalize-login" + } + } + + // ───────────────────── Retry wrapper ───────────────────── + private class RestHandlers( + private val verify: (code: Int, acceptable: IntArray, message: String) -> Unit, + private val retries: Int = 3 + ) { + fun run( + block: () -> Pair, + acceptable: IntArray + ): String { + var err: Throwable? = null + repeat(max(1, retries)) { + try { + val (code, body) = block() + verify(code, acceptable, body) + return body + } catch (t: Throwable) { + err = t + } + } + throw err ?: IllegalStateException("Request failed after $retries attempts") + } + } + + private val restHandlers = RestHandlers(::verifyRequestResult, retries = 3) + + private fun verifyRequestResult(currentCode: Int, acceptable: IntArray, message: String) { + if (acceptable.any { it == currentCode }) return + throw Exception( + "Request to Okta API failed. " + + "Request return code is: $currentCode. " + + "Expected codes are: ${acceptable.contentToString()}. " + + "Message from service is: $message" + ) + } + + @Suppress("NestedBlockDepth") + private fun httpRequest( + path: String, + method: String, + acceptableCodes: IntArray, + accept: String = "application/json", + body: String? = null + ): String = restHandlers.run( + block = { + val url = URL("$BASE_URI/$path") + val conn = (url.openConnection() as HttpsURLConnection).apply { + requestMethod = method + setRequestProperty("Authorization", "SSWS $apiKey") + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", accept) + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + if (method == "POST" || method == "PUT") { + doOutput = true + body?.let { outputStream.bufferedWriter().use { w -> w.write(it) } } + } + } + val code = conn.responseCode + val text = (if (code in acceptableCodes) conn.inputStream else conn.errorStream) + ?.bufferedReader()?.use(BufferedReader::readText).orEmpty() + conn.disconnect() + code to text + }, + acceptable = acceptableCodes + ) + + private fun readRawResource(context: Context, resId: Int): String = + context.resources.openRawResource(resId).bufferedReader().use { it.readText() } + + private fun hardSleep(ms: Long) { + try { + Thread.sleep(ms) + } catch (_: InterruptedException) { + } + } + + fun createApplication(label: String, finalizeUrl: String, context: Context): String { + val template = readRawResource(context, R.raw.app_creation) + val requestBody = JSONObject(template).apply { + put("label", label) + val settings = getJSONObject("settings") + val signOn = settings.getJSONObject("signOn").apply { + put("ssoAcsUrl", finalizeUrl) + put("audience", finalizeUrl) + put("recipient", finalizeUrl) + put("destination", finalizeUrl) + } + settings.put("signOn", signOn) + put("settings", settings) + } + + val output = httpRequest( + path = "api/v1/apps", + method = "POST", + body = requestBody.toString(), + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + + val response = JSONObject(output) + applicationId = response.getString("id") + val groupId = fetchGroupId("Everyone") + assignApplicationToGroup(requireNotNull(applicationId), groupId) + + // NEW: wait until the assignment is visible to GETs (propagation) + waitForAppGroupLink(requireNotNull(applicationId), groupId) + + // Tiny grace period (Okta can still be warming up) + hardSleep(1200) + + return requireNotNull(applicationId) + } + + private fun fetchGroupId(groupName: String): String { + val output = httpRequest( + path = "api/v1/groups?limit=100", + method = "GET", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + val arr = JSONArray(output) + for (i in 0 until arr.length()) { + val group = arr.getJSONObject(i) + if (group.getJSONObject("profile").getString("name") == groupName) { + return group.getString("id") + } + } + throw IllegalStateException("Cannot fetch id of a group with name '$groupName'") + } + + private fun assignApplicationToGroup(appId: String, groupId: String) { + httpRequest( + path = "api/v1/apps/$appId/groups/$groupId", + method = "PUT", + body = "{}", // Okta often expects a JSON object even if empty + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_NO_CONTENT) + ) + } + + // poll for the app-group link to be visible + private fun waitForAppGroupLink(appId: String, groupId: String, timeoutMs: Long = 30_000) { + val deadline = System.currentTimeMillis() + timeoutMs + var lastErr: Throwable? = null + while (System.currentTimeMillis() < deadline) { + try { + httpRequest( + path = "api/v1/apps/$appId/groups/$groupId", + method = "GET", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + return // success + } catch (t: Throwable) { + lastErr = t + hardSleep(700) + } + } + throw lastErr ?: IllegalStateException("Timed out waiting for app $appId to link group $groupId") + } + + fun getApplicationMetadata(): String { + val appId = applicationId ?: error("Application ID is not set. Create an application first.") + return httpRequest( + path = "api/v1/apps/$appId/sso/saml/metadata", + method = "GET", + accept = "application/xml", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + } + + fun createUser(name: String, email: String, password: String) { + val requestBody = JSONObject().apply { + put( + "profile", + JSONObject().apply { + put( + "firstName", + name + ) + put( + "lastName", + name + ) + put( + "email", + email + ) + put( + "login", + email + ) + } + ) + put( + "credentials", + JSONObject().apply { + put( + "password", + JSONObject().put( + "value", + password + ) + ) + put( + "recovery_question", + JSONObject().apply { + put( + "question", + "What is the answer to life, the universe and everything?" + ) + put( + "answer", + "fortytwo" + ) + } + ) + } + ) + } + + val output = httpRequest( + path = "api/v1/users?activate=true", + method = "POST", + body = requestBody.toString(), + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + val response = JSONObject(output) + userIds.add(response.getString("id")) + network.WireTestLogger.getLog("LogginUserId").info("User id is ${response.getString("id")}") + } + + fun deleteUser(userId: String) { + httpRequest( + path = "api/v1/users/$userId/lifecycle/deactivate", + method = "POST", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + val response = httpRequest( + path = "api/v1/users/$userId", + method = "DELETE", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_NO_CONTENT) + ) + network.WireTestLogger.getLog("Delete user").info("Delete user response is $response") + } + + fun cleanUp() { + if (applicationId != null) { + httpRequest( + path = "api/v1/apps/$applicationId/lifecycle/deactivate", + method = "POST", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) + ) + httpRequest( + path = "api/v1/apps/$applicationId", + method = "DELETE", + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_NO_CONTENT) + ) + } + for (userId in userIds) { + deleteUser(userId) + } + } +} diff --git a/tests/testsSupport/src/main/res/raw/app_creation.json b/tests/testsSupport/src/main/res/raw/app_creation.json new file mode 100644 index 00000000000..1edd1906153 --- /dev/null +++ b/tests/testsSupport/src/main/res/raw/app_creation.json @@ -0,0 +1,31 @@ +{ + "label": "Custom Saml 2.0 App2", + "visibility": { + "autoSubmitToolbar": false + }, + "signOnMode": "SAML_2_0", + "credentials": { + "userNameTemplate": { + "template": "${fn:substringBefore(source.login, \"@\")}", + "type": "BUILT_IN" + } + }, + "settings": { + "signOn": { + "ssoAcsUrl": "will be replaced", + "idpIssuer": "https://www.okta.com/${org.externalKey}", + "audience": "will be replaced", + "recipient": "will be replaced", + "destination": "will be replaced", + "subjectNameIdTemplate": "${user.email}", + "subjectNameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "responseSigned": true, + "assertionSigned": true, + "signatureAlgorithm": "RSA_SHA256", + "digestAlgorithm": "SHA256", + "honorForceAuthn": true, + "authnContextClassRef": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + "requestCompressed": false + } + } +} diff --git a/tests/testsSupport/src/main/service/SSOServiceHelper.kt b/tests/testsSupport/src/main/service/SSOServiceHelper.kt new file mode 100644 index 00000000000..d559dbe5c5c --- /dev/null +++ b/tests/testsSupport/src/main/service/SSOServiceHelper.kt @@ -0,0 +1,119 @@ +import android.content.Context +import backendUtils.BackendClient.Companion.AUTHORIZATION +import backendUtils.BackendClient.Companion.accept +import backendUtils.BackendClient.Companion.applicationJson +import backendUtils.BackendClient.Companion.contentType +import backendUtils.BackendClient.Companion.loadBackend +import backendUtils.team.getTeamByName +import kotlinx.coroutines.runBlocking +import network.NetworkBackendClient +import network.NumberSequence +import network.RequestOptions +import network.WireTestLogger +import okta.OktaApiClient +import org.json.JSONObject +import service.TestServiceHelper +import user.utils.ClientUser +import java.net.URL +import java.net.URLEncoder +import java.util.UUID + +object SSOServiceHelper { + + var identityProviderId = "" + + suspend fun TestServiceHelper.thereIsASSOTeamOwnerForOkta( + context: Context, + ownerNameAlias: String, + teamName: String, + client: OktaApiClient + ) { + val owner = toClientUser(ownerNameAlias) + thereIsATeamOwner(context, ownerNameAlias, teamName, true, backend = loadBackend(owner.backendName ?: "STAGING")) + enableSSOFeature(owner, teamName) + val backend = loadBackend(owner.backendName.orEmpty()) + val finalizeUrl = OktaApiClient.getFinalizeUrlDependingOnBackend(backend.backendUrl) + val client = OktaApiClient() + client.createApplication(owner.name + " " + teamName + UUID.randomUUID().toString(), finalizeUrl, context) + + val metadata = client.getApplicationMetadata() + identityProviderId = + backend.createIdentityProvider( + owner, + metadata + ) + } + + @Suppress("TooGenericExceptionThrown", "MagicNumber") + suspend fun TestServiceHelper.userAddsOktaUser(ownerNameAlias: String, userNameAliases: String, oktaApiClient: OktaApiClient) { + val aliases = usersManager.splitAliases(userNameAliases) + for (userNameAlias in aliases) { + val user = toClientUser(userNameAlias) + + if (usersManager.isUserCreated(user)) { + throw Exception( + "Cannot add user with alias $userNameAlias to SSO team because user is already created" + ) + } + + user.password = "SSO${user.password}" + user.name = user.email + + // Backend generates the unique username through the email. We try to predict this here: + var uniqueUsername = user.email?.replace(Regex("[^A-Za-z0-9]"), "") + if ((uniqueUsername?.length ?: 0) > 21) { + uniqueUsername = uniqueUsername?.substring(0, 21) + } + user.uniqueUsername = uniqueUsername + + val ownerBackendName = toClientUser(ownerNameAlias).backendName + user.backendName = ownerBackendName + user.setUserIsSSOUser() + + syncUserIdsForUsersCreatedThroughIdP(ownerNameAlias, user) + + // set backend for added okta users + user.backendName = ownerBackendName + + oktaApiClient.createUser(user.name.orEmpty(), user.email.orEmpty(), user.password.orEmpty()) + } + } + + fun TestServiceHelper.userXIsMe(nameAlias: String) { + usersManager.setSelfUser(toClientUser(nameAlias)) + } + + fun getSSOCode(): String = "wire-$identityProviderId".also { + WireTestLogger.getLog("Test Log").info("The sso code is $it") + } + + fun clearSSOCode() { + identityProviderId = "" + } + + @Suppress("MagicNumber") + fun enableSSOFeature(clientUser: ClientUser, teamName: String) { + val backend = loadBackend(clientUser.backendName.orEmpty()) + val dstTeam = runBlocking { + backend.getTeamByName(clientUser, teamName) + } + val url = URL("${backend.backendUrl}i/teams/${URLEncoder.encode(dstTeam.id, "UTF-8")}/features/sso") + val headers = mapOf( + AUTHORIZATION to backend.basicAuth.getEncoded(), + accept to applicationJson, + contentType to applicationJson + ) + + val requestBody = JSONObject().apply { + put("status", "enabled") + } + + NetworkBackendClient.sendJsonRequest( + url = url, + method = "PUT", + body = requestBody.toString(), + headers = headers, + options = RequestOptions(expectedResponseCodes = NumberSequence.Array(intArrayOf(200))), + ) + } +} diff --git a/tests/testsSupport/src/main/service/TestServiceHelper.kt b/tests/testsSupport/src/main/service/TestServiceHelper.kt index 7af72b0f5a2..eef54ad0419 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -35,6 +35,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.time.Duration +import java.util.concurrent.Callable import java.util.concurrent.TimeUnit class TestServiceHelper { @@ -354,6 +355,46 @@ class TestServiceHelper { return this != 0 } + @Suppress("LongParameterList") + fun thereIsATeamOwner( + context: Context, + ownerNameAlias: String, + teamName: String, + updateHandle: Boolean, + locale: String = "en_US", + backend: BackendClient = BackendClient.getDefault()!! + ) { + val owner = toClientUser(ownerNameAlias) + if (usersManager.isUserCreated(owner)) { + throw Exception( + "Cannot create team with user ${owner.nameAliases} as owner because user is already created" + ) + } + usersManager.createTeamOwnerByAlias(ownerNameAlias, teamName, locale, updateHandle, backend, context) + } + + fun syncUserIdsForUsersCreatedThroughIdP(ownerNameAlias: String, user: ClientUser) { + user.getUserIdThroughOwner = Callable { + val asUser = toClientUser(ownerNameAlias) + val backend = BackendClient.loadBackend(asUser.backendName.orEmpty()) + val teamMembers = backend.getTeamMembers(asUser) + + for (member in teamMembers) { + val memberId = member.userId + + val memberName = backend.getUserNameByID(backend.domain, memberId, asUser) + + if (user.name == memberName) { + return@Callable memberId + } + } + + throw IOException( + "No user ID found for user ${user.email}. Please verify you are using the right Team Owner account" + ) + } + } + fun toClientUser(nameAlias: String): ClientUser { return usersManager.findUserByNameOrNameAlias(nameAlias) } diff --git a/tests/testsSupport/src/main/service/models/TeamMember.kt b/tests/testsSupport/src/main/service/models/TeamMember.kt new file mode 100644 index 00000000000..74056f8a9f9 --- /dev/null +++ b/tests/testsSupport/src/main/service/models/TeamMember.kt @@ -0,0 +1,25 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package service.models + +import backendUtils.team.TeamRole + +data class TeamMember( + val userId: String, + val role: TeamRole +) diff --git a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt index 1aa241d5ca0..e967b276051 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -2,28 +2,31 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import java.io.IOException -fun deleteDownloadedFilesContainingFileWord() { +private const val DOWNLOAD_DIR = "/sdcard/Download" + +fun deleteDownloadedFilesContaining(keyword: String, dir: String = DOWNLOAD_DIR) { try { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val listCommand = "ls /sdcard/Download" + val listCommand = "ls $dir" val fileListOutput = device.executeShellCommand(listCommand) val matchingFiles = fileListOutput .split("\n") .map { it.trim() } - .filter { it.contains("File", ignoreCase = true) } // <- This is the keyword match + .filter { it.contains(keyword, ignoreCase = true) } if (matchingFiles.isEmpty()) { - println("⚠️ No files found containing 'File'") + println("No files found containing '$keyword'") return } for (file in matchingFiles) { - val deleteCommand = "rm -f /sdcard/Download/$file" + if (file.isBlank()) continue + val deleteCommand = "rm -f $dir/$file" val result = device.executeShellCommand(deleteCommand) - println("✅ Deleted: $file. Output: $result") + println("Deleted: $file. Output: $result") } } catch (e: IOException) { - println("❌ Error while deleting files: ${e.message}") + println("Error while deleting files: ${e.message}") } } diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index d4906c2d5cd..ad41099a1e6 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -17,6 +17,7 @@ */ package uiautomatorutils +import android.graphics.Rect import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -25,6 +26,7 @@ import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until import java.io.IOException private const val TIMEOUT_IN_MILLISECONDS = 10000L @@ -80,45 +82,63 @@ object UiWaitUtils { } } - @Suppress("MagicNumber", "NestedBlockDepth") + @Suppress("MagicNumber", "NestedBlockDepth", "CyclomaticComplexMethod", "ComplexCondition") fun waitElement( params: UiSelectorParams, - timeoutMillis: Long = 10_000, - pollingInterval: Long = 250 + timeoutMillis: Long = 10_000 ): UiObject2 { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val selector = buildSelector(params) - val deadline = SystemClock.uptimeMillis() + timeoutMillis + val sel = buildSelector(params) - while (SystemClock.uptimeMillis() < deadline) { - try { - val obj = device.findObject(selector) - if (obj != null) { - try { - if (!obj.visibleBounds.isEmpty) return obj - } catch (e: StaleObjectException) { - // Ignore and retry + // 1) Block until node exists + if (!device.wait(Until.hasObject(sel), timeoutMillis)) { + throw AssertionError("Element not found with selector: ${describe(params)}") + } + + device.waitForIdle(500) + + // 2) Stabilize: refetch until bounds are stable & usable + val end = SystemClock.uptimeMillis() + 1_500 + var lastBounds: Rect? = null + + while (SystemClock.uptimeMillis() < end) { + val obj = try { + device.findObject(sel) + } catch (_: StaleObjectException) { + null + } + if (obj != null) { + try { + val b = obj.visibleBounds + val onScreen = b.left >= 0 && b.top >= 0 && + b.right <= device.displayWidth && b.bottom <= device.displayHeight + val nonZero = b.width() > 0 && b.height() > 0 + val enabled = obj.isEnabled + + // Same bounds twice in a row → considered stable + if (onScreen && nonZero && enabled && lastBounds != null && lastBounds == b) { + return obj } + lastBounds = b + } catch (_: StaleObjectException) { + // re-loop } - } catch (e: StaleObjectException) { - // Ignore and retry } - SystemClock.sleep(pollingInterval) + SystemClock.sleep(100) } - throw AssertionError( - "Element not found or not visible with selector: " + - listOfNotNull( - params.text?.let { "text='$it'" }, - params.textContains?.let { "textContains='$it'" }, - params.resourceId?.let { "resourceId='$it'" }, - params.className?.let { "className='$it'" }, - params.description?.let { "description='$it'" } - ).joinToString(", ") - ) + throw AssertionError("Element found but not stable/visible with selector: ${describe(params)}") } + private fun describe(params: UiSelectorParams) = listOfNotNull( + params.text?.let { "text='$it'" }, + params.textContains?.let { "textContains='$it'" }, + params.resourceId?.let { "resourceId='$it'" }, + params.className?.let { "className='$it'" }, + params.description?.let { "description='$it'" } + ).joinToString(", ") + fun waitUntilElementGone( device: UiDevice, selector: UiSelector,