From ebff369a5268232d8d98cafde83e63cf9b0ba853 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Mon, 29 Sep 2025 14:14:12 +0200 Subject: [PATCH 1/9] login with sso and backup messages --- kalium | 2 +- .../core/criticalFlows/AccountManagement.kt | 8 +- .../tests/core/criticalFlows/FileSharing.kt | 2 +- .../core/criticalFlows/GroupMessaging.kt | 4 +- .../core/criticalFlows/NewMemberMessaging.kt | 10 +- .../criticalFlows/PersonalAccountLifeCycle.kt | 6 +- .../core/criticalFlows/SSODeviceBackup.kt | 227 +++++++++++++++++ .../wire/android/tests/core/pages/AllPages.kt | 6 + .../android/tests/core/pages/ChromePage.kt | 57 +++++ .../tests/core/pages/ConversationListPage.kt | 34 ++- .../tests/core/pages/ConversationViewPage.kt | 22 +- .../android/tests/core/pages/LoginPage.kt | 31 +++ .../tests/core/pages/RegistrationPage.kt | 4 +- .../wire/android/tests/core/pages/SSOPage.kt | 47 ++++ .../android/tests/core/pages/SearchPage.kt | 14 ++ .../tests/core/pages/SelfUserProfilePage.kt | 69 ++++++ .../android/tests/core/pages/SettingsPage.kt | 104 +++++++- .../android/tests/core/tests/ApplockTest.kt | 2 +- .../wire/android/tests/core/tests/GdprTest.kt | 4 +- .../tests/PersonalUserRegistrationTest.kt | 2 +- .../src/main/backendUtils/BackendClient.kt | 97 +++++++- .../main/backendUtils/team/TeamsExtension.kt | 20 ++ .../src/main/okta/OktaApiClient.kt | 231 ++++++++++++++++++ .../src/main/res/raw/app_creation.json | 31 +++ .../src/main/service/SSOServiceHelper.kt | 106 ++++++++ .../src/main/service/TestServiceHelper.kt | 42 ++++ .../src/main/service/models/TeamMember.kt | 25 ++ .../src/main/uiautomatorutils/FileUtils.kt | 32 ++- 28 files changed, 1203 insertions(+), 36 deletions(-) create mode 100644 tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt create mode 100644 tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ChromePage.kt create mode 100644 tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt create mode 100644 tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SelfUserProfilePage.kt create mode 100644 tests/testsSupport/src/main/okta/OktaApiClient.kt create mode 100644 tests/testsSupport/src/main/res/raw/app_creation.json create mode 100644 tests/testsSupport/src/main/service/SSOServiceHelper.kt create mode 100644 tests/testsSupport/src/main/service/models/TeamMember.kt diff --git a/kalium b/kalium index 232f8f5b0ca..3111cb97b6e 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 232f8f5b0ca95f6a2f8c20869f85467f4b35de43 +Subproject commit 3111cb97b6ecc360c1c4e1ceb24b9dffe6b57d1f 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..1fe54287888 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 @@ -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..90680fb7c20 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt @@ -0,0 +1,227 @@ +/* + * 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 deleteDownloadedFilesContainingWireWord +import kotlinx.coroutines.runBlocking +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 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 + + 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") + } + + @After + fun tearDown() { + teamOwner?.deleteTeam(backendClient!!) + deleteDownloadedFilesContainingWireWord() + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + @Test + fun givenSSOTeamWithOkta_whenSettingUpNewDeviceAndRestoringBackup_thenMessageIsRestored() { + + runBlocking { + teamServiceHelper.thereIsASSOTeamOwnerForOkta(context, "user1Name", "Messaging") + + teamServiceHelper.userAddsOktaUser("user1Name", "user2Name") + 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.chromePage.apply { + clearChromeBrowserCache() + } + pages.loginPage.apply { + enterSSOCodeOnSSOLoginTab(ssoCode) + clickLoginButton() + } + pages.chromePage.apply { + clickUseWithoutAccount() + } + pages.ssoPage.apply { + enterOktaEmail(member1?.email ?: "") + enterOktaPassword(member1?.password ?: "") + 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..fdfc46c6acb --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ChromePage.kt @@ -0,0 +1,57 @@ +/* + * 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.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() +} 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/src/main/backendUtils/BackendClient.kt b/tests/testsSupport/src/main/backendUtils/BackendClient.kt index 43da72cecc9..2bbde04b70e 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, asUser) + } + + private fun getTeamMembers(token: AccessToken, teamId: String, asUser: ClientUser): 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..326b9eacd75 --- /dev/null +++ b/tests/testsSupport/src/main/okta/OktaApiClient.kt @@ -0,0 +1,231 @@ +/* + * 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 okta + +import android.content.Context +import com.wire.android.testSupport.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.net.HttpURLConnection +import java.net.URL +import java.util.logging.Logger +import javax.net.ssl.HttpsURLConnection + +class OktaApiClient { + + // Properties to hold the state + private var applicationId: String? = null + private val userIds = mutableSetOf() + + companion object { + private val log = Logger.getLogger(OktaApiClient::class.java.simpleName) + + private const val BASE_URI = "https://dev-500508-admin.oktapreview.com" + + private val apiKey: String by lazy { + "00fcJCxjNc2t2s2e9KeLFMaH3ZeufpDW2kRN4ElhEu" + } + + fun getFinalizeUrlDependingOnBackend(backendUrl: String): String { + val trimmedUrl = backendUrl.removeSuffix("/") + return "$trimmedUrl/sso/finalize-login" + } + } + + /** + * Core suspend function to handle all HTTP requests asynchronously. + * It uses HttpURLConnection and runs on the IO dispatcher. + */ + private suspend fun makeRequest( + path: String, + method: String, + body: String? = null, + expectedStatusCodes: List, + acceptHeader: String = "application/json" + ): String = withContext(Dispatchers.IO) { + val url = URL("$BASE_URI/$path") + val connection = (url.openConnection() as HttpsURLConnection).apply { + requestMethod = method + setRequestProperty("Authorization", "SSWS $apiKey") + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", acceptHeader) + connectTimeout = 3000 // 3 seconds + readTimeout = 240000 // 240 seconds + + if (method == "POST" || method == "PUT") { + doOutput = true + body?.let { + outputStream.bufferedWriter().use { writer -> writer.write(it) } + } + } + } + + val responseCode = connection.responseCode + val responseStream = if (responseCode in expectedStatusCodes) { + connection.inputStream + } else { + connection.errorStream + } + + val responseBody = responseStream.bufferedReader().use(BufferedReader::readText) + + if (responseCode !in expectedStatusCodes) { + throw Exception("Request Failed: $responseCode ${connection.responseMessage}. Body: $responseBody") + } + + connection.disconnect() + responseBody + } + + /** + * Reads a resource file from the classpath. + */ + private fun readRawResource(context: Context, resId: Int): String { + context.resources.openRawResource(resId).bufferedReader().use { reader -> + return reader.readText() + } + } + + suspend fun createApplication(label: String, finalizeUrl: String, context: Context): String { + val template = readRawResource(context,R.raw.app_creation) // Note the leading slash + 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 responseJson = makeRequest( + path = "api/v1/apps", + method = "POST", + body = requestBody.toString(), + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + ) + + this.applicationId = JSONObject(responseJson).getString("id") + val groupId = fetchGroupId("Everyone") + assignApplicationToGroup(this.applicationId!!, groupId) + return this.applicationId!! + } + + suspend 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 responseJson = makeRequest( + path = "api/v1/users?activate=true", + method = "POST", + body = requestBody.toString(), + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + ) + val userId = JSONObject(responseJson).getString("id") + userIds.add(userId) + } + + private suspend fun fetchGroupId(groupName: String): String { + val responseJson = makeRequest( + path = "api/v1/groups?limit=100", + method = "GET", + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + ) + val groups = JSONArray(responseJson) + for (i in 0 until groups.length()) { + val group = groups.getJSONObject(i) + if (group.getJSONObject("profile").getString("name") == groupName) { + return group.getString("id") + } + } + throw IllegalStateException("Cannot fetch id of a group with name '$groupName'") + } + + suspend fun getApplicationMetadata(): String { + val appId = applicationId ?: throw IllegalStateException("Application ID is not set. Create an application first.") + return makeRequest( + path = "api/v1/apps/$appId/sso/saml/metadata", + method = "GET", + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK), + acceptHeader = "application/xml" // Special case for metadata + ) + } + + private suspend fun assignApplicationToGroup(appId: String, groupId: String) { + makeRequest( + path = "api/v1/apps/$appId/groups/$groupId", + method = "PUT", + body = "{}", // Body can be empty but some APIs require it to be a valid JSON object + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + ) + } + + private suspend fun deleteUser(userId: String) { + // 1. Deactivate the user + makeRequest( + path = "api/v1/users/$userId/lifecycle/deactivate", + method = "POST", + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + ) + // 2. Delete the user + makeRequest( + path = "api/v1/users/$userId", + method = "DELETE", + expectedStatusCodes = listOf(HttpURLConnection.HTTP_NO_CONTENT) + ) + } + + suspend fun cleanUp() { + applicationId?.let { appId -> + // Deactivate app + makeRequest( + path = "api/v1/apps/$appId/lifecycle/deactivate", + method = "POST", + expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + ) + // Delete app + makeRequest( + path = "api/v1/apps/$appId", + method = "DELETE", + expectedStatusCodes = listOf(HttpURLConnection.HTTP_NO_CONTENT) + ) + } + userIds.forEach { deleteUser(it) } + userIds.clear() + applicationId = null + } +} 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..b85a8bb76ab --- /dev/null +++ b/tests/testsSupport/src/main/service/SSOServiceHelper.kt @@ -0,0 +1,106 @@ +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 + +object SSOServiceHelper { + var identityProviderId = "" + suspend fun TestServiceHelper.thereIsASSOTeamOwnerForOkta(context: Context, ownerNameAlias: String, teamName: String) { + 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, finalizeUrl, context) + + val metadata = client.getApplicationMetadata() + identityProviderId = + backend.createIdentityProvider( + owner, + metadata + ) + } + + fun TestServiceHelper.userAddsOktaUser(ownerNameAlias: String, userNameAliases: String) { + 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 + + runBlocking { + 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 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..bc20e9bbd89 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -26,6 +26,7 @@ import com.wire.android.testSupport.R import com.wire.android.testSupport.service.TestService import kotlinx.coroutines.runBlocking import network.HttpRequestException +import okta.OktaApiClient import service.enums.LegalHoldStatus import service.models.Conversation import service.models.SendTextParams @@ -35,6 +36,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 +356,46 @@ class TestServiceHelper { return this != 0 } + 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..fe8cea2cefc 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -14,16 +14,42 @@ fun deleteDownloadedFilesContainingFileWord() { .filter { it.contains("File", ignoreCase = true) } // <- This is the keyword match if (matchingFiles.isEmpty()) { - println("⚠️ No files found containing 'File'") + println("No files found containing 'File'") return } for (file in matchingFiles) { val deleteCommand = "rm -f /sdcard/Download/$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}") + } +} + +fun deleteDownloadedFilesContainingWireWord() { + try { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val listCommand = "ls /sdcard/Download" + val fileListOutput = device.executeShellCommand(listCommand) + + val matchingFiles = fileListOutput + .split("\n") + .map { it.trim() } + .filter { it.contains("Wire", ignoreCase = true) } // <- This is the keyword match + + if (matchingFiles.isEmpty()) { + println("No files found containing 'Wire'") + return + } + + for (file in matchingFiles) { + val deleteCommand = "rm -f /sdcard/Download/$file" + val result = device.executeShellCommand(deleteCommand) + println("Deleted: $file. Output: $result") + } + } catch (e: IOException) { + println("Error while deleting files: ${e.message}") } } From 34a9f2a95d164fffaeb8f6598c0226909f708355 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Mon, 29 Sep 2025 20:29:24 +0200 Subject: [PATCH 2/9] fix duplicate flagged by sonarQube --- .../tests/core/criticalFlows/FileSharing.kt | 8 ++-- .../core/criticalFlows/SSODeviceBackup.kt | 4 +- .../src/main/uiautomatorutils/FileUtils.kt | 37 ++++--------------- 3 files changed, 13 insertions(+), 36 deletions(-) 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 1fe54287888..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") 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 index 90680fb7c20..77d6ab091e8 100644 --- 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 @@ -31,7 +31,7 @@ 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 deleteDownloadedFilesContainingWireWord +import deleteDownloadedFilesContaining import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -77,7 +77,7 @@ class SSODeviceBackup : KoinTest { @After fun tearDown() { teamOwner?.deleteTeam(backendClient!!) - deleteDownloadedFilesContainingWireWord() + deleteDownloadedFilesContaining("Wire") } @Suppress("CyclomaticComplexMethod", "LongMethod") diff --git a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt index fe8cea2cefc..e967b276051 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -2,50 +2,27 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import java.io.IOException -fun deleteDownloadedFilesContainingFileWord() { - try { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val listCommand = "ls /sdcard/Download" - val fileListOutput = device.executeShellCommand(listCommand) - - val matchingFiles = fileListOutput - .split("\n") - .map { it.trim() } - .filter { it.contains("File", ignoreCase = true) } // <- This is the keyword match - - if (matchingFiles.isEmpty()) { - println("No files found containing 'File'") - return - } - - for (file in matchingFiles) { - val deleteCommand = "rm -f /sdcard/Download/$file" - val result = device.executeShellCommand(deleteCommand) - println("Deleted: $file. Output: $result") - } - } catch (e: IOException) { - println("Error while deleting files: ${e.message}") - } -} +private const val DOWNLOAD_DIR = "/sdcard/Download" -fun deleteDownloadedFilesContainingWireWord() { +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("Wire", ignoreCase = true) } // <- This is the keyword match + .filter { it.contains(keyword, ignoreCase = true) } if (matchingFiles.isEmpty()) { - println("No files found containing 'Wire'") + 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") } From a2a7d3c8fd683d6916f8fd001c89f301be04ec3f Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Tue, 30 Sep 2025 17:55:47 +0200 Subject: [PATCH 3/9] fix sonarcloud flags --- tests/testsSupport/build.gradle.kts | 13 ++-- .../src/main/backendUtils/BackendClient.kt | 4 +- .../src/main/okta/OktaApiClient.kt | 70 ++++++++++++++----- .../src/main/service/SSOServiceHelper.kt | 9 ++- .../src/main/service/TestServiceHelper.kt | 5 +- 5 files changed, 70 insertions(+), 31 deletions(-) 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 2bbde04b70e..dd6a0721980 100644 --- a/tests/testsSupport/src/main/backendUtils/BackendClient.kt +++ b/tests/testsSupport/src/main/backendUtils/BackendClient.kt @@ -426,10 +426,10 @@ class BackendClient( fun getTeamMembers(asUser: ClientUser): List { val firstTeam = getAllTeams(asUser).first() - return getTeamMembers(runBlocking { getAuthToken(asUser)!! }, firstTeam.id, asUser) + return getTeamMembers(runBlocking { getAuthToken(asUser)!! }, firstTeam.id) } - private fun getTeamMembers(token: AccessToken, teamId: String, asUser: ClientUser): List { + private fun getTeamMembers(token: AccessToken, teamId: String): List { val url = URL("teams/$teamId/members".composeCompleteUrl()) val headers = defaultheaders.toMutableMap().apply { diff --git a/tests/testsSupport/src/main/okta/OktaApiClient.kt b/tests/testsSupport/src/main/okta/OktaApiClient.kt index 326b9eacd75..41b21d1bd7a 100644 --- a/tests/testsSupport/src/main/okta/OktaApiClient.kt +++ b/tests/testsSupport/src/main/okta/OktaApiClient.kt @@ -18,6 +18,7 @@ package okta import android.content.Context +import com.wire.android.testSupport.BuildConfig import com.wire.android.testSupport.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -26,7 +27,6 @@ import org.json.JSONObject import java.io.BufferedReader import java.net.HttpURLConnection import java.net.URL -import java.util.logging.Logger import javax.net.ssl.HttpsURLConnection class OktaApiClient { @@ -36,12 +36,11 @@ class OktaApiClient { private val userIds = mutableSetOf() companion object { - private val log = Logger.getLogger(OktaApiClient::class.java.simpleName) private const val BASE_URI = "https://dev-500508-admin.oktapreview.com" private val apiKey: String by lazy { - "00fcJCxjNc2t2s2e9KeLFMaH3ZeufpDW2kRN4ElhEu" + BuildConfig.OKTA_API_KEY_PASSWORD "00fcJCxjNc2t2s2e9KeLFMaH3ZeufpDW2kRN4ElhEu" } fun getFinalizeUrlDependingOnBackend(backendUrl: String): String { @@ -50,6 +49,7 @@ class OktaApiClient { } } + @Suppress("TooGenericExceptionThrown", "MagicNumber") /** * Core suspend function to handle all HTTP requests asynchronously. * It uses HttpURLConnection and runs on the IO dispatcher. @@ -105,7 +105,7 @@ class OktaApiClient { } suspend fun createApplication(label: String, finalizeUrl: String, context: Context): String { - val template = readRawResource(context,R.raw.app_creation) // Note the leading slash + val template = readRawResource(context, R.raw.app_creation) // Note the leading slash val requestBody = JSONObject(template).apply { put("label", label) val settings = getJSONObject("settings") @@ -134,19 +134,52 @@ class OktaApiClient { suspend 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") - }) - }) + 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 responseJson = makeRequest( @@ -176,7 +209,8 @@ class OktaApiClient { } suspend fun getApplicationMetadata(): String { - val appId = applicationId ?: throw IllegalStateException("Application ID is not set. Create an application first.") + val appId = + applicationId ?: throw IllegalStateException("Application ID is not set. Create an application first.") return makeRequest( path = "api/v1/apps/$appId/sso/saml/metadata", method = "GET", diff --git a/tests/testsSupport/src/main/service/SSOServiceHelper.kt b/tests/testsSupport/src/main/service/SSOServiceHelper.kt index b85a8bb76ab..e2a0c0ccdf0 100644 --- a/tests/testsSupport/src/main/service/SSOServiceHelper.kt +++ b/tests/testsSupport/src/main/service/SSOServiceHelper.kt @@ -23,7 +23,7 @@ object SSOServiceHelper { val owner = toClientUser(ownerNameAlias) thereIsATeamOwner(context, ownerNameAlias, teamName, true, backend = loadBackend(owner.backendName ?: "STAGING")) enableSSOFeature(owner, teamName) - val backend = loadBackend(owner.backendName.orEmpty()); + val backend = loadBackend(owner.backendName.orEmpty()) val finalizeUrl = OktaApiClient.getFinalizeUrlDependingOnBackend(backend.backendUrl) val client = OktaApiClient() client.createApplication(owner.name + " " + teamName, finalizeUrl, context) @@ -36,6 +36,7 @@ object SSOServiceHelper { ) } + @Suppress("TooGenericExceptionThrown", "MagicNumber") fun TestServiceHelper.userAddsOktaUser(ownerNameAlias: String, userNameAliases: String) { val aliases = usersManager.splitAliases(userNameAliases) for (userNameAlias in aliases) { @@ -76,8 +77,11 @@ object SSOServiceHelper { usersManager.setSelfUser(toClientUser(nameAlias)) } - fun getSSOCode(): String = "wire-$identityProviderId".also { WireTestLogger.getLog ("Test Log").info("The sso code is $it") } + fun getSSOCode(): String = "wire-$identityProviderId".also { + WireTestLogger.getLog("Test Log").info("The sso code is $it") + } + @Suppress("MagicNumber") fun enableSSOFeature(clientUser: ClientUser, teamName: String) { val backend = loadBackend(clientUser.backendName.orEmpty()) val dstTeam = runBlocking { @@ -102,5 +106,4 @@ object SSOServiceHelper { 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 bc20e9bbd89..eef54ad0419 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -26,7 +26,6 @@ import com.wire.android.testSupport.R import com.wire.android.testSupport.service.TestService import kotlinx.coroutines.runBlocking import network.HttpRequestException -import okta.OktaApiClient import service.enums.LegalHoldStatus import service.models.Conversation import service.models.SendTextParams @@ -356,6 +355,7 @@ class TestServiceHelper { return this != 0 } + @Suppress("LongParameterList") fun thereIsATeamOwner( context: Context, ownerNameAlias: String, @@ -370,7 +370,7 @@ class TestServiceHelper { "Cannot create team with user ${owner.nameAliases} as owner because user is already created" ) } - usersManager.createTeamOwnerByAlias(ownerNameAlias, teamName, locale, updateHandle, backend,context) + usersManager.createTeamOwnerByAlias(ownerNameAlias, teamName, locale, updateHandle, backend, context) } fun syncUserIdsForUsersCreatedThroughIdP(ownerNameAlias: String, user: ClientUser) { @@ -379,7 +379,6 @@ class TestServiceHelper { val backend = BackendClient.loadBackend(asUser.backendName.orEmpty()) val teamMembers = backend.getTeamMembers(asUser) - for (member in teamMembers) { val memberId = member.userId From 00dd2ea9e248fccd857438dcdb71687b1afa0c49 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Tue, 30 Sep 2025 18:01:18 +0200 Subject: [PATCH 4/9] pull api key password from 1password --- tests/testsSupport/src/main/okta/OktaApiClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testsSupport/src/main/okta/OktaApiClient.kt b/tests/testsSupport/src/main/okta/OktaApiClient.kt index 41b21d1bd7a..f47291f0fb4 100644 --- a/tests/testsSupport/src/main/okta/OktaApiClient.kt +++ b/tests/testsSupport/src/main/okta/OktaApiClient.kt @@ -40,7 +40,7 @@ class OktaApiClient { private const val BASE_URI = "https://dev-500508-admin.oktapreview.com" private val apiKey: String by lazy { - BuildConfig.OKTA_API_KEY_PASSWORD "00fcJCxjNc2t2s2e9KeLFMaH3ZeufpDW2kRN4ElhEu" + BuildConfig.OKTA_API_KEY_PASSWORD } fun getFinalizeUrlDependingOnBackend(backendUrl: String): String { From e8e8b2f7af8279d2fd3b09f5316089bc9953e675 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Mon, 6 Oct 2025 09:36:22 +0200 Subject: [PATCH 5/9] fixing flaky test and sonarcloud flag --- .../core/criticalFlows/SSODeviceBackup.kt | 285 ++++++++------- .../android/tests/core/pages/ChromePage.kt | 9 + .../src/main/okta/OktaApiClient.kt | 330 ++++++++++-------- .../src/main/service/SSOServiceHelper.kt | 22 +- .../src/main/uiautomatorutils/UiWaitUtils.kt | 72 ++-- 5 files changed, 411 insertions(+), 307 deletions(-) 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 index 77d6ab091e8..19fe56c5e4a 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -41,6 +42,7 @@ 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 @@ -58,6 +60,7 @@ class SSODeviceBackup : KoinTest { 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 { @@ -72,12 +75,14 @@ class SSODeviceBackup : KoinTest { 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") @@ -85,143 +90,157 @@ class SSODeviceBackup : KoinTest { fun givenSSOTeamWithOkta_whenSettingUpNewDeviceAndRestoringBackup_thenMessageIsRestored() { runBlocking { - teamServiceHelper.thereIsASSOTeamOwnerForOkta(context, "user1Name", "Messaging") - teamServiceHelper.userAddsOktaUser("user1Name", "user2Name") + 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() + teamOwner = teamHelper?.usersManager!!.findUserBy( + "user1Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + member1 = teamHelper?.usersManager!!.findUserBy( + "user2Name", + ClientUserManager.FindBy.NAME_ALIAS + ) - pages.registrationPage.apply { - assertEmailWelcomePage() - } - pages.loginPage.apply { - clickStagingDeepLink() - clickProceedButtonOnDeeplinkOverlay() - } - pages.chromePage.apply { - clearChromeBrowserCache() - } - pages.loginPage.apply { - enterSSOCodeOnSSOLoginTab(ssoCode) - clickLoginButton() - } - pages.chromePage.apply { - clickUseWithoutAccount() - } - pages.ssoPage.apply { - enterOktaEmail(member1?.email ?: "") - enterOktaPassword(member1?.password ?: "") - 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") + 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) + //Thread.sleep(20000) + 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() + Thread.sleep(1000) + 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/ChromePage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ChromePage.kt index fdfc46c6acb..46fc7dcb16c 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -55,3 +56,11 @@ data class ChromePage(private val device: UiDevice) { .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/testsSupport/src/main/okta/OktaApiClient.kt b/tests/testsSupport/src/main/okta/OktaApiClient.kt index f47291f0fb4..51fa3fabca5 100644 --- a/tests/testsSupport/src/main/okta/OktaApiClient.kt +++ b/tests/testsSupport/src/main/okta/OktaApiClient.kt @@ -15,97 +15,118 @@ * 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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext 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 { - // Properties to hold the state + 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 = mutableSetOf() + private val userIds = LinkedHashSet() companion object { - - private const val BASE_URI = "https://dev-500508-admin.oktapreview.com" - - private val apiKey: String by lazy { - BuildConfig.OKTA_API_KEY_PASSWORD - } - + @JvmStatic fun getFinalizeUrlDependingOnBackend(backendUrl: String): String { - val trimmedUrl = backendUrl.removeSuffix("/") - return "$trimmedUrl/sso/finalize-login" + val trimmed = backendUrl.removeSuffix("/") + return "$trimmed/sso/finalize-login" } } - @Suppress("TooGenericExceptionThrown", "MagicNumber") - /** - * Core suspend function to handle all HTTP requests asynchronously. - * It uses HttpURLConnection and runs on the IO dispatcher. - */ - private suspend fun makeRequest( - path: String, - method: String, - body: String? = null, - expectedStatusCodes: List, - acceptHeader: String = "application/json" - ): String = withContext(Dispatchers.IO) { - val url = URL("$BASE_URI/$path") - val connection = (url.openConnection() as HttpsURLConnection).apply { - requestMethod = method - setRequestProperty("Authorization", "SSWS $apiKey") - setRequestProperty("Content-Type", "application/json") - setRequestProperty("Accept", acceptHeader) - connectTimeout = 3000 // 3 seconds - readTimeout = 240000 // 240 seconds - - if (method == "POST" || method == "PUT") { - doOutput = true - body?.let { - outputStream.bufferedWriter().use { writer -> writer.write(it) } + // ───────────────────── 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") } + } - val responseCode = connection.responseCode - val responseStream = if (responseCode in expectedStatusCodes) { - connection.inputStream - } else { - connection.errorStream - } + private val restHandlers = RestHandlers(::verifyRequestResult, retries = 3) - val responseBody = responseStream.bufferedReader().use(BufferedReader::readText) + 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" + ) + } - if (responseCode !in expectedStatusCodes) { - throw Exception("Request Failed: $responseCode ${connection.responseMessage}. Body: $responseBody") - } + @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 + ) - connection.disconnect() - responseBody - } + private fun readRawResource(context: Context, resId: Int): String = + context.resources.openRawResource(resId).bufferedReader().use { it.readText() } - /** - * Reads a resource file from the classpath. - */ - private fun readRawResource(context: Context, resId: Int): String { - context.resources.openRawResource(resId).bufferedReader().use { reader -> - return reader.readText() + private fun hardSleep(ms: Long) { + try { + Thread.sleep(ms) + } catch (_: InterruptedException) { } } - suspend fun createApplication(label: String, finalizeUrl: String, context: Context): String { - val template = readRawResource(context, R.raw.app_creation) // Note the leading slash + 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") @@ -119,41 +140,104 @@ class OktaApiClient { put("settings", settings) } - val responseJson = makeRequest( + val output = httpRequest( path = "api/v1/apps", method = "POST", body = requestBody.toString(), - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) ) - this.applicationId = JSONObject(responseJson).getString("id") + val response = JSONObject(output) + applicationId = response.getString("id") val groupId = fetchGroupId("Everyone") - assignApplicationToGroup(this.applicationId!!, groupId) - return this.applicationId!! + 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) + ) } - suspend fun createUser(name: String, email: String, password: String) { + 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( + "firstName", + name + ) + put( + "lastName", + name + ) + put( + "email", + email + ) + put( + "login", + email + ) + } ) put( "credentials", @@ -182,84 +266,46 @@ class OktaApiClient { ) } - val responseJson = makeRequest( + val output = httpRequest( path = "api/v1/users?activate=true", method = "POST", body = requestBody.toString(), - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) ) - val userId = JSONObject(responseJson).getString("id") - userIds.add(userId) + val response = JSONObject(output) + userIds.add(response.getString("id")) + network.WireTestLogger.getLog("LogginUserId").info("User id is ${response.getString("id")}") } - private suspend fun fetchGroupId(groupName: String): String { - val responseJson = makeRequest( - path = "api/v1/groups?limit=100", - method = "GET", - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) - ) - val groups = JSONArray(responseJson) - for (i in 0 until groups.length()) { - val group = groups.getJSONObject(i) - if (group.getJSONObject("profile").getString("name") == groupName) { - return group.getString("id") - } - } - throw IllegalStateException("Cannot fetch id of a group with name '$groupName'") - } - - suspend fun getApplicationMetadata(): String { - val appId = - applicationId ?: throw IllegalStateException("Application ID is not set. Create an application first.") - return makeRequest( - path = "api/v1/apps/$appId/sso/saml/metadata", - method = "GET", - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK), - acceptHeader = "application/xml" // Special case for metadata - ) - } - - private suspend fun assignApplicationToGroup(appId: String, groupId: String) { - makeRequest( - path = "api/v1/apps/$appId/groups/$groupId", - method = "PUT", - body = "{}", // Body can be empty but some APIs require it to be a valid JSON object - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) - ) - } - - private suspend fun deleteUser(userId: String) { - // 1. Deactivate the user - makeRequest( + fun deleteUser(userId: String) { + httpRequest( path = "api/v1/users/$userId/lifecycle/deactivate", method = "POST", - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) ) - // 2. Delete the user - makeRequest( + val response = httpRequest( path = "api/v1/users/$userId", method = "DELETE", - expectedStatusCodes = listOf(HttpURLConnection.HTTP_NO_CONTENT) + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_NO_CONTENT) ) + network.WireTestLogger.getLog("Delete user").info("Delete user response is $response") } - suspend fun cleanUp() { - applicationId?.let { appId -> - // Deactivate app - makeRequest( - path = "api/v1/apps/$appId/lifecycle/deactivate", + fun cleanUp() { + if (applicationId != null) { + httpRequest( + path = "api/v1/apps/$applicationId/lifecycle/deactivate", method = "POST", - expectedStatusCodes = listOf(HttpURLConnection.HTTP_OK) + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_OK) ) - // Delete app - makeRequest( - path = "api/v1/apps/$appId", + httpRequest( + path = "api/v1/apps/$applicationId", method = "DELETE", - expectedStatusCodes = listOf(HttpURLConnection.HTTP_NO_CONTENT) + acceptableCodes = intArrayOf(HttpURLConnection.HTTP_NO_CONTENT) ) } - userIds.forEach { deleteUser(it) } - userIds.clear() - applicationId = null + for (userId in userIds) { + deleteUser(userId) + } } } diff --git a/tests/testsSupport/src/main/service/SSOServiceHelper.kt b/tests/testsSupport/src/main/service/SSOServiceHelper.kt index e2a0c0ccdf0..d559dbe5c5c 100644 --- a/tests/testsSupport/src/main/service/SSOServiceHelper.kt +++ b/tests/testsSupport/src/main/service/SSOServiceHelper.kt @@ -16,17 +16,25 @@ 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) { + + 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, finalizeUrl, context) + client.createApplication(owner.name + " " + teamName + UUID.randomUUID().toString(), finalizeUrl, context) val metadata = client.getApplicationMetadata() identityProviderId = @@ -37,7 +45,7 @@ object SSOServiceHelper { } @Suppress("TooGenericExceptionThrown", "MagicNumber") - fun TestServiceHelper.userAddsOktaUser(ownerNameAlias: String, userNameAliases: String) { + suspend fun TestServiceHelper.userAddsOktaUser(ownerNameAlias: String, userNameAliases: String, oktaApiClient: OktaApiClient) { val aliases = usersManager.splitAliases(userNameAliases) for (userNameAlias in aliases) { val user = toClientUser(userNameAlias) @@ -67,9 +75,7 @@ object SSOServiceHelper { // set backend for added okta users user.backendName = ownerBackendName - runBlocking { - OktaApiClient().createUser(user.name.orEmpty(), user.email.orEmpty(), user.password.orEmpty()) - } + oktaApiClient.createUser(user.name.orEmpty(), user.email.orEmpty(), user.password.orEmpty()) } } @@ -81,6 +87,10 @@ object SSOServiceHelper { 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()) diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index d4906c2d5cd..f9ac383a627 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(p: UiSelectorParams) = listOfNotNull( + p.text?.let { "text='$it'" }, + p.textContains?.let { "textContains='$it'" }, + p.resourceId?.let { "resourceId='$it'" }, + p.className?.let { "className='$it'" }, + p.description?.let { "description='$it'" } + ).joinToString(", ") + fun waitUntilElementGone( device: UiDevice, selector: UiSelector, From c37f9c214a74532227a86ac01370ac6858cb6952 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Wed, 8 Oct 2025 18:06:57 +0200 Subject: [PATCH 6/9] add retry and hard wait for okta to propagate --- .../wire/android/tests/core/criticalFlows/SSODeviceBackup.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 19fe56c5e4a..c3dd53fffc1 100644 --- 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 @@ -129,8 +129,7 @@ class SSODeviceBackup : KoinTest { pages.ssoPage.apply { enterOktaEmail(member1?.email ?: "") enterOktaPassword(member1?.password ?: "") - waitFor(20) - //Thread.sleep(20000) + waitFor(20) // Delay added to allow Okta app assignment to fully sync and avoid 403 error tapOktaSignIn() } pages.registrationPage.apply { From 1238ce974c6a843950726dbe240d0bfe42bfad7e Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Wed, 8 Oct 2025 18:58:40 +0200 Subject: [PATCH 7/9] refactor wait utility --- .../src/main/uiautomatorutils/UiWaitUtils.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index f9ac383a627..ad41099a1e6 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -131,12 +131,12 @@ object UiWaitUtils { throw AssertionError("Element found but not stable/visible with selector: ${describe(params)}") } - private fun describe(p: UiSelectorParams) = listOfNotNull( - p.text?.let { "text='$it'" }, - p.textContains?.let { "textContains='$it'" }, - p.resourceId?.let { "resourceId='$it'" }, - p.className?.let { "className='$it'" }, - p.description?.let { "description='$it'" } + 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( From 09bd29a3705b81c7e998c319e73ef3042f528eb1 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Wed, 8 Oct 2025 19:35:05 +0200 Subject: [PATCH 8/9] remove add wait --- kalium | 2 +- .../wire/android/tests/core/criticalFlows/SSODeviceBackup.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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/SSODeviceBackup.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt index c3dd53fffc1..06ee9b28ae4 100644 --- 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 @@ -173,7 +173,6 @@ class SSODeviceBackup : KoinTest { pages.settingsPage.apply { openBackupAndRestoreConversationsMenu() iSeeBackupPageHeading() - Thread.sleep(1000) clickCreateBackupButton() clickBackUpNowButton() iSeeBackupConfirmation("Conversations successfully saved") From 6dd9b2894ea9fcc2bb3bc28b748abb371151bc83 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Fri, 10 Oct 2025 15:54:13 +0200 Subject: [PATCH 9/9] Update Kalium submodule to 59f008442a --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 546cdfe457a..59f008442a6 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 546cdfe457ad8953ad7497bbfcd0cbbb645150b3 +Subproject commit 59f008442a67f9026fd66e410da35835819874d8