From ec135795a5f4cba988bbb740eaaa564551a254bc Mon Sep 17 00:00:00 2001 From: Corbet Weller Date: Wed, 4 Nov 2026 14:25:55 -0500 Subject: [PATCH] QA-1407: test: Add native end-to-end UI test POC for login and vault item Proof of concept for native Compose UI tests against a real self-hosted server, with test data from the SeederApi. Ports two Appium smoke cases (Testmo 22270 login, 22279 read login item). - Screen objects per app screen, a minimal SeederApi client with play-id cleanup, and config from instrumentation arguments (https only, production hosts refused). - Test orchestrator with clearPackageData so each test starts fresh. - Espresso pinned to 3.7.0: Compose's 3.5.0 crashes on API 37. - README covers running locally without Gradle storing the token. --- app/build.gradle.kts | 15 +++ app/src/androidTest/README.md | 55 +++++++++ .../com/x8bit/bitwarden/e2e/BaseE2eTest.kt | 61 ++++++++++ .../com/x8bit/bitwarden/e2e/VaultLoginTest.kt | 52 ++++++++ .../x8bit/bitwarden/e2e/config/E2eConfig.kt | 55 +++++++++ .../screens/AccessibilityDisclosureScreen.kt | 25 ++++ .../e2e/screens/ComposeTestExtensions.kt | 115 ++++++++++++++++++ .../e2e/screens/EnvironmentScreen.kt | 19 +++ .../bitwarden/e2e/screens/LandingScreen.kt | 38 ++++++ .../bitwarden/e2e/screens/LoginScreen.kt | 21 ++++ .../bitwarden/e2e/screens/VaultItemScreen.kt | 33 +++++ .../bitwarden/e2e/screens/VaultScreen.kt | 26 ++++ .../bitwarden/e2e/screens/WelcomeScreen.kt | 17 +++ .../x8bit/bitwarden/e2e/seeder/SeededData.kt | 88 ++++++++++++++ .../bitwarden/e2e/seeder/SeederClient.kt | 90 ++++++++++++++ .../bitwarden/e2e/seeder/SeederModels.kt | 75 ++++++++++++ gradle/libs.versions.toml | 10 ++ 17 files changed, 795 insertions(+) create mode 100644 app/src/androidTest/README.md create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/BaseE2eTest.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/VaultLoginTest.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/config/E2eConfig.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/AccessibilityDisclosureScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/ComposeTestExtensions.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/EnvironmentScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LandingScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LoginScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultItemScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/WelcomeScreen.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeededData.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederClient.kt create mode 100644 app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederModels.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b82ed0651e..81ef1e15ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,8 @@ configure { versionName = libs.versions.appVersionName.get() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Each instrumented test starts from a freshly cleared app (requires the orchestrator). + testInstrumentationRunnerArguments["clearPackageData"] = "true" buildConfigField( type = "String", @@ -163,6 +165,9 @@ configure { } } testOptions { + // Instrumented tests: isolates each test in its own process (enables clearPackageData). + execution = "ANDROIDX_TEST_ORCHESTRATOR" + // Required for Robolectric unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true @@ -313,6 +318,16 @@ dependencies { testImplementation(libs.mockk.mockk) testImplementation(libs.robolectric.robolectric) testImplementation(libs.square.turbine) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test) + // Compose pulls in Espresso 3.5.0, which crashes on API 37 (InputManager.getInstance removed). + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestUtil(libs.androidx.test.orchestrator) + androidTestUtil(libs.androidx.test.services) } afterEvaluate { diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md new file mode 100644 index 0000000000..0c91e41d4c --- /dev/null +++ b/app/src/androidTest/README.md @@ -0,0 +1,55 @@ +# End-to-end UI tests + +Instrumented tests that drive the real app against a real self-hosted server, with test data +created by the [SeederApi](https://github.com/bitwarden/server/tree/main/util/SeederApi). They +live in `kotlin/com/x8bit/bitwarden/e2e/`: + +- `screens/`: one screen object per app screen, with intent-level methods. +- `seeder/`: a minimal SeederApi client and the data it seeds. +- `BaseE2eTest`: launches the app, seeds data, and deletes it after each test. + +These are a proof of concept (QA-1407). Test method names carry the Testmo case ID. + +## Requirements + +- JDK 21 and a `user.properties` with `gitHubToken` (see the root README). +- An emulator or device running the **standard** flavor's debug build. The F-Droid flavor skips + the accessibility disclosure these tests expect. +- A self-hosted server reachable over https, with a SeederApi writing to the same database. + Servers on a local network also trigger Android's local network access screen, which the tests + don't handle. + +## Running + +> **Warning:** the test orchestrator clears `com.x8bit.bitwarden.dev`'s data before every test. +> Don't run these on a device whose debug install you want to keep. + +The tests read three instrumentation arguments: + +| Argument | Value | +|---|---| +| `BW_TEST_SERVER_URL` | Base URL of the server under test (https; production hosts are refused) | +| `BW_SEEDER_URL` | SeederApi base URL (https) | +| `BW_SEEDER_TOKEN` | SeederApi Basic credential: base64 of `username:password` | + +Prefer `am instrument` so the token is never stored by Gradle (Gradle records +`-Pandroid.testInstrumentationRunnerArguments.*` values in its execution history): + +```shell +./gradlew :app:installStandardDebug :app:installStandardDebugAndroidTest +adb shell pm clear com.x8bit.bitwarden.dev +adb shell am instrument -w \ + -e class com.x8bit.bitwarden.e2e.VaultLoginTest#test_22279_readUsernameAndPassword \ + -e BW_TEST_SERVER_URL \ + -e BW_SEEDER_URL \ + -e BW_SEEDER_TOKEN \ + com.x8bit.bitwarden.dev.test/androidx.test.runner.AndroidJUnitRunner +``` + +`./gradlew :app:connectedStandardDebugAndroidTest` with the same arguments also works and uses the +orchestrator, but use it only on ephemeral machines (CI) for the reason above. Plain +`connectedAndroidTest` also runs the F-Droid variant. + +The debug app logs HTTP bodies, and saved test outputs +(`app/build/outputs/androidTest-results/`) include logcat with the seeded account's tokens. Don't +publish them. diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/BaseE2eTest.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/BaseE2eTest.kt new file mode 100644 index 0000000000..fe12f779db --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/BaseE2eTest.kt @@ -0,0 +1,61 @@ +package com.x8bit.bitwarden.e2e + +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import com.x8bit.bitwarden.MainActivity +import com.x8bit.bitwarden.e2e.config.E2eConfig +import com.x8bit.bitwarden.e2e.screens.AccessibilityDisclosureScreen +import com.x8bit.bitwarden.e2e.screens.VaultScreen +import com.x8bit.bitwarden.e2e.seeder.SeededAccount +import com.x8bit.bitwarden.e2e.seeder.SeederClient +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Rule + +/** + * Base class for end-to-end tests against a real server: launches [MainActivity], provides a + * [SeederClient], and deletes seeded data after each test. The test orchestrator clears app data + * between tests, so every test starts from the accessibility disclosure. + */ +abstract class BaseE2eTest { + /** + * Launches the app and drives its Compose UI. Effects run eagerly: MainActivity only removes + * its splash screen from a composition effect, which the v2 default (queued) dispatcher never + * runs during launch. + */ + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val composeRule = createAndroidComposeRule( + effectContext = UnconfinedTestDispatcher(), + ) + + /** + * Environment settings from the instrumentation arguments. + */ + protected val config: E2eConfig = E2eConfig.fromInstrumentationArguments() + + /** + * Seeds this test's data; everything it creates shares one play id. + */ + protected val seeder: SeederClient = SeederClient(config) + + /** + * Removes this test's seeded data. + */ + @After + fun deleteSeededData() { + seeder.deleteSeededData() + } + + /** + * Points the app at the server under test and logs in as [account]. + */ + protected fun logIn(account: SeededAccount): VaultScreen = + AccessibilityDisclosureScreen(composeRule) + .acknowledge() + .chooseLogIn() + .selectSelfHosted() + .setServerUrl(config.serverUrl) + .continueWithEmail(account.email) + .logIn(account.password) +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/VaultLoginTest.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/VaultLoginTest.kt new file mode 100644 index 0000000000..f338cae0be --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/VaultLoginTest.kt @@ -0,0 +1,52 @@ +package com.x8bit.bitwarden.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.x8bit.bitwarden.e2e.seeder.LoginItem +import com.x8bit.bitwarden.e2e.seeder.addLoginItem +import com.x8bit.bitwarden.e2e.seeder.createFreeUser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Native ports of the Appium/C# login and vault smoke tests. Test names carry the Testmo case ID + * (backtick names with spaces can't be dexed below API 30, and minSdk is 29). + */ +@RunWith(AndroidJUnit4::class) +class VaultLoginTest : BaseE2eTest() { + /** + * Testmo 22270: a seeded user logs in and reaches the vault. + */ + @Test + fun test_22270_logIn() { + val account = seeder.createFreeUser() + + val vault = logIn(account) + + vault.settingsTab.assertIsDisplayed() + } + + /** + * Testmo 22279: a seeded login item shows its username, password and URI. + */ + @Test + fun test_22279_readUsernameAndPassword() { + val account = seeder.createFreeUser() + val item = LoginItem( + name = "Cipher-22279", + username = "Cipher-22279-User", + password = "Cipher-22279-Password", + uri = "www.test.com", + ) + seeder.addLoginItem(account, item) + + val itemScreen = logIn(account).openItem(item.name) + + assertEquals(item.username, itemScreen.username()) + assertNotEquals(item.password, itemScreen.displayedPassword()) + assertEquals(item.password, itemScreen.revealPassword().displayedPassword()) + assertEquals(item.uri, itemScreen.uri()) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/config/E2eConfig.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/config/E2eConfig.kt new file mode 100644 index 0000000000..e2b6392076 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/config/E2eConfig.kt @@ -0,0 +1,55 @@ +package com.x8bit.bitwarden.e2e.config + +import android.net.Uri +import androidx.test.platform.app.InstrumentationRegistry + +/** + * Environment settings for end-to-end tests, read from instrumentation arguments + * (`-e BW_TEST_SERVER_URL `). Nothing is hard-coded, and [toString] never exposes the token. + * + * @property serverUrl Base URL of the self-hosted server under test. + * @property seederUrl Base URL of the SeederApi that writes test data to that server's database. + * @property seederToken SeederApi Basic credential (base64 of `username:password`). + */ +class E2eConfig( + val serverUrl: String, + val seederUrl: String, + val seederToken: String, +) { + override fun toString(): String = "E2eConfig(serverUrl=$serverUrl, seederUrl=$seederUrl)" + + companion object { + /** + * Reads the configuration from instrumentation arguments, failing fast when one is missing + * or unsafe. + */ + fun fromInstrumentationArguments(): E2eConfig { + val arguments = InstrumentationRegistry.getArguments() + fun required(key: String): String = + requireNotNull(arguments.getString(key)?.takeIf { it.isNotBlank() }) { + "Missing instrumentation argument '$key'." + } + return E2eConfig( + serverUrl = requireTestServer(required("BW_TEST_SERVER_URL")), + seederUrl = requireHttps(required("BW_SEEDER_URL")).trimEnd('/'), + seederToken = required("BW_SEEDER_TOKEN"), + ) + } + + private fun requireHttps(url: String): String { + require(url.startsWith("https://")) { "Expected an https URL, got '$url'." } + return url + } + + // These tests seed and log in throwaway accounts; never point them at production. + private fun requireTestServer(url: String): String { + val host = Uri.parse(requireHttps(url)).host.orEmpty().lowercase().trimEnd('.') + require(PRODUCTION_DOMAINS.none { host == it || host.endsWith(".$it") }) { + "Refusing to run end-to-end tests against production host '$host'." + } + return url + } + } +} + +private val PRODUCTION_DOMAINS = listOf("bitwarden.com", "bitwarden.eu") diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/AccessibilityDisclosureScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/AccessibilityDisclosureScreen.kt new file mode 100644 index 0000000000..0fcf035607 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/AccessibilityDisclosureScreen.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import com.bitwarden.ui.platform.resource.BitwardenString + +/** + * Accessibility Service Disclosure, shown on every launch of the standard flavor until it has + * been acknowledged. + */ +class AccessibilityDisclosureScreen(private val rule: ComposeTestRule) { + /** + * Acknowledges the disclosure, revealing the welcome screen. + */ + fun acknowledge(): WelcomeScreen { + // The button has no testTag yet, so it is matched by its localized label. + rule.waitForNode( + hasClickAction() and hasAnyDescendant(hasText(appString(BitwardenString.i_understand))), + ).performClick() + return WelcomeScreen(rule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/ComposeTestExtensions.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/ComposeTestExtensions.kt new file mode 100644 index 0000000000..606b0f97d0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/ComposeTestExtensions.kt @@ -0,0 +1,115 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.annotation.StringRes +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.platform.app.InstrumentationRegistry + +/** + * Waits until at least one node matches [matcher] and returns it; actions on the result require + * the match to be unique. Fails with a timeout naming [matcher] if nothing matches. + */ +fun ComposeTestRule.waitForNode(matcher: SemanticsMatcher): SemanticsNodeInteraction { + waitUntil(conditionDescription = matcher.description, timeoutMillis = NETWORK_TIMEOUT_MS) { + onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + return onNode(matcher, useUnmergedTree = true) +} + +/** + * Waits until a node with [testTag] exists and returns it. + */ +fun ComposeTestRule.waitForTag(testTag: String): SemanticsNodeInteraction = + waitForNode(hasTestTag(testTag)) + +/** + * Like [waitForNode] for content inside a lazy list, which only composes visible items: scrolls + * the screen's list to [matcher] on each attempt, so the node is found on any screen size. + */ +fun ComposeTestRule.scrollToNode(matcher: SemanticsMatcher): SemanticsNodeInteraction { + waitUntil(conditionDescription = matcher.description, timeoutMillis = NETWORK_TIMEOUT_MS) { + val lists = onAllNodes(hasScrollToNodeAction(), useUnmergedTree = true) + lists.fetchSemanticsNodes().isNotEmpty() && lists.onFirst().scrolledTo(matcher) + } + return onNode(matcher, useUnmergedTree = true) +} + +/** + * Like [scrollToNode], then clicks the node. Scroll and click are retried together because the + * list can recompose between them and briefly drop the node (seen on the vault after login). + */ +fun ComposeTestRule.scrollToAndClick(matcher: SemanticsMatcher) { + waitUntil(conditionDescription = matcher.description, timeoutMillis = NETWORK_TIMEOUT_MS) { + val lists = onAllNodes(hasScrollToNodeAction(), useUnmergedTree = true) + lists.fetchSemanticsNodes().isNotEmpty() && + lists.onFirst().scrolledTo(matcher) && + onNode(matcher, useUnmergedTree = true).clicked() + } +} + +/** + * Waits until no node matches [matcher], e.g. for a transient message to be dismissed. + */ +fun ComposeTestRule.waitForNodeToDisappear(matcher: SemanticsMatcher) { + waitUntil( + conditionDescription = "no node ${matcher.description}", + timeoutMillis = NETWORK_TIMEOUT_MS, + ) { + onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isEmpty() + } +} + +/** + * The field's raw value, before any visual transformation. + */ +fun SemanticsNodeInteraction.rawText(): String { + val config = fetchSemanticsNode().config + return config.getOrNull(SemanticsProperties.InputText)?.text + ?: config.getOrNull(SemanticsProperties.EditableText)?.text + ?: error("Node has no text field value: ${fetchSemanticsNode().config}") +} + +/** + * The field's value as displayed, after visual transformation (e.g. a masked password). + */ +fun SemanticsNodeInteraction.displayedText(): String = + fetchSemanticsNode().config.getOrNull(SemanticsProperties.EditableText)?.text + ?: error("Node has no displayed text field value: ${fetchSemanticsNode().config}") + +/** + * The app's localized string for [id], so text matching works in any device locale. + */ +fun appString(@StringRes id: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + +private fun SemanticsNodeInteraction.scrolledTo(matcher: SemanticsMatcher): Boolean = + try { + performScrollToNode(matcher) + true + } catch (_: AssertionError) { + // The list doesn't contain the node yet (e.g. sync still in progress). + false + } + +private fun SemanticsNodeInteraction.clicked(): Boolean = + try { + performClick() + true + } catch (_: AssertionError) { + // The node was gone before the touch was injected, so nothing was clicked. + false + } + +/** + * Upper bound for waits that depend on the network (login, sync), which Compose idling + * doesn't track. + */ +private const val NETWORK_TIMEOUT_MS = 30_000L diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/EnvironmentScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/EnvironmentScreen.kt new file mode 100644 index 0000000000..91fd6478b9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/EnvironmentScreen.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement + +/** + * Self-hosted environment settings. + */ +class EnvironmentScreen(private val rule: ComposeTestRule) { + /** + * Sets the server URL and saves, returning to the landing screen. + */ + fun setServerUrl(url: String): LandingScreen { + rule.waitForTag("ServerUrlEntry").performTextReplacement(url) + rule.waitForTag("SaveButton").performClick() + return LandingScreen(rule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LandingScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LandingScreen.kt new file mode 100644 index 0000000000..3c9de91ce0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LandingScreen.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import com.bitwarden.ui.platform.resource.BitwardenString + +/** + * Email entry screen with the region (server) selector. + */ +class LandingScreen(private val rule: ComposeTestRule) { + /** + * Picks "Self-hosted" in the region selector, which opens the environment screen. + */ + fun selectSelfHosted(): EnvironmentScreen { + rule.waitForTag("RegionSelectorDropdown").performClick() + rule.waitForNode( + hasTestTag("AlertRadioButtonOption") and + hasAnyDescendant(hasText(appString(BitwardenString.self_hosted))), + ).performClick() + return EnvironmentScreen(rule) + } + + /** + * Enters [email] and continues to the master password screen. + */ + fun continueWithEmail(email: String): LoginScreen { + // The "environment saved" snackbar can sit over Continue and take the tap. + rule.waitForNodeToDisappear(hasText(appString(BitwardenString.environment_saved))) + rule.waitForTag("EmailAddressEntry").performTextReplacement(email) + rule.waitForTag("ContinueButton").performScrollTo().performClick() + return LoginScreen(rule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LoginScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LoginScreen.kt new file mode 100644 index 0000000000..4b20ada920 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/LoginScreen.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement + +/** + * Master password screen. + */ +class LoginScreen(private val rule: ComposeTestRule) { + /** + * Logs in with [password] and returns the vault once its tab bar is shown. + */ + fun logIn(password: String): VaultScreen { + rule.waitForTag("MasterPasswordEntry").performTextReplacement(password) + rule.waitForTag("LogInWithMasterPasswordButton").performScrollTo().performClick() + rule.waitForTag("SettingsTab") + return VaultScreen(rule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultItemScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultItemScreen.kt new file mode 100644 index 0000000000..13bcd1fc9e --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultItemScreen.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule + +/** + * View screen of a login item. + */ +class VaultItemScreen(private val rule: ComposeTestRule) { + /** + * The item's username. + */ + fun username(): String = rule.scrollToNode(hasTestTag("LoginUsernameEntry")).rawText() + + /** + * The password as currently displayed: masked until [revealPassword] is used. + */ + fun displayedPassword(): String = + rule.scrollToNode(hasTestTag("LoginPasswordEntry")).displayedText() + + /** + * Toggles the password to visible. + */ + fun revealPassword(): VaultItemScreen { + rule.scrollToAndClick(hasTestTag("LoginViewPasswordButton")) + return this + } + + /** + * The item's URI (the item must have exactly one). + */ + fun uri(): String = rule.scrollToNode(hasTestTag("LoginUriEntry")).rawText() +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultScreen.kt new file mode 100644 index 0000000000..9a6e8cf71b --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/VaultScreen.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule + +/** + * The unlocked vault with the bottom tab bar. + */ +class VaultScreen(private val rule: ComposeTestRule) { + /** + * The settings tab, which only exists in the unlocked vault's navigation bar. + */ + val settingsTab: SemanticsNodeInteraction + get() = rule.waitForTag("SettingsTab") + + /** + * Opens the item named exactly [name] from the vault list. + */ + fun openItem(name: String): VaultItemScreen { + rule.scrollToAndClick(hasTestTag("CipherCell") and hasAnyDescendant(hasText(name))) + return VaultItemScreen(rule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/WelcomeScreen.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/WelcomeScreen.kt new file mode 100644 index 0000000000..2fba6fec71 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/screens/WelcomeScreen.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.e2e.screens + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick + +/** + * First-launch welcome screen. + */ +class WelcomeScreen(private val rule: ComposeTestRule) { + /** + * Chooses to log in with an existing account. + */ + fun chooseLogIn(): LandingScreen { + rule.waitForTag("ChooseLoginButton").performClick() + return LandingScreen(rule) + } +} diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeededData.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeededData.kt new file mode 100644 index 0000000000..de8f0d9e30 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeededData.kt @@ -0,0 +1,88 @@ +package com.x8bit.bitwarden.e2e.seeder + +import java.util.UUID + +/** + * A user created through the Seeder. + * + * @property email The address stored by the server (mangled); the one to log in with. + * @property password Master password. + * @property userId The user's ID. + * @property userKeyB64 The user's symmetric key, used to seed their vault items. + */ +class SeededAccount( + val email: String, + val password: String, + val userId: String, + val userKeyB64: String, +) + +/** + * A login item to seed for a user. Plain values; the Seeder encrypts them server-side. + * + * @property name Item name. + * @property username Login username. + * @property password Login password. + * @property uri Login URI. + */ +data class LoginItem( + val name: String, + val username: String, + val password: String, + val uri: String, +) + +/** + * Seeds a Free account on the self-hosted server under test and returns its credentials. + */ +fun SeederClient.createFreeUser(): SeededAccount { + // Unique even before mangling, so reruns and parallel runs never collide. + val originalEmail = "native-e2e-${UUID.randomUUID()}@example.com" + val password = "Native-E2E-${UUID.randomUUID()}" + val response = seed( + template = "SingleUserScene", + arguments = SingleUserSceneArguments( + email = originalEmail, + password = password, + emailVerified = true, + selfHosted = true, + kdfIterations = KDF_ITERATIONS, + ), + argumentsSerializer = SingleUserSceneArguments.serializer(), + resultSerializer = SingleUserSceneResult.serializer(), + ) + // Mangling and play-id tracking are enabled together on the Seeder; without them the + // cleanup in BaseE2eTest would silently delete nothing. + val email = requireNotNull(response.mangleMap[originalEmail]) { + "Seeder returned no mangled email; play-id tracking is off, so seeded data can't be " + + "cleaned up." + } + return SeededAccount( + email = email, + password = password, + userId = response.result.userId, + userKeyB64 = response.result.decryptedKeyB64, + ) +} + +/** + * Seeds [item] into [account]'s personal vault. + */ +fun SeederClient.addLoginItem(account: SeededAccount, item: LoginItem) { + seed( + template = "UserLoginCipherScene", + arguments = UserLoginCipherSceneArguments( + userId = account.userId, + userKeyB64 = account.userKeyB64, + name = item.name, + username = item.username, + password = item.password, + uri = item.uri, + ), + argumentsSerializer = UserLoginCipherSceneArguments.serializer(), + resultSerializer = UserLoginCipherSceneResult.serializer(), + ) +} + +// Matches the C# suite's production-realistic default; lower values trigger the app's KDF prompt. +private const val KDF_ITERATIONS = 600_000 diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederClient.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederClient.kt new file mode 100644 index 0000000000..5b6b5d4b67 --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederClient.kt @@ -0,0 +1,90 @@ +package com.x8bit.bitwarden.e2e.seeder + +import com.x8bit.bitwarden.e2e.config.E2eConfig +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Minimal SeederApi client: `POST /seed` to create data and `DELETE /seed/{playId}` to remove it. + * Every request carries this client's [playId], so one call deletes everything it seeded. + */ +class SeederClient(private val config: E2eConfig) { + /** + * Identifies all data seeded by this client. + */ + val playId: String = UUID.randomUUID().toString() + + /** + * Seeds the scene [template] with [arguments] and returns the decoded response. + */ + fun seed( + template: String, + arguments: A, + argumentsSerializer: KSerializer, + resultSerializer: KSerializer, + ): SeedResponse { + val body = buildJsonObject { + put("template", template) + put("arguments", json.encodeToJsonElement(argumentsSerializer, arguments)) + } + val request = requestFor("${config.seederUrl}/seed") + .post(body.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + httpClient.newCall(request).execute().use { response -> + val text = response.body.string() + check(response.isSuccessful) { + "Seeding $template failed: HTTP ${response.code} ${text.take(MAX_ERROR_BODY)}" + } + return json.decodeFromString(SeedResponse.serializer(resultSerializer), text) + } + } + + /** + * Deletes everything seeded with [playId]. Logs instead of throwing, so cleanup never hides + * the real test failure. + */ + fun deleteSeededData() { + val request = requestFor("${config.seederUrl}/seed/$playId").delete().build() + try { + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.w("Cleanup of play id $playId failed: HTTP ${response.code}") + } + } + } catch (e: IOException) { + Timber.w(e, "Cleanup of play id $playId failed") + } + } + + private fun requestFor(url: String): Request.Builder = + Request.Builder() + .url(url) + .header("Authorization", "Basic ${config.seederToken}") + .header("x-play-id", playId) +} + +private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +// Shared so tests don't each create their own connection pool. Seeding can be slow on a cold +// server, so the read timeout is longer than OkHttp's default. +private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + +private val JSON_MEDIA_TYPE = "application/json".toMediaType() + +private const val MAX_ERROR_BODY = 500 diff --git a/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederModels.kt b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederModels.kt new file mode 100644 index 0000000000..52cfc123dc --- /dev/null +++ b/app/src/androidTest/kotlin/com/x8bit/bitwarden/e2e/seeder/SeederModels.kt @@ -0,0 +1,75 @@ +package com.x8bit.bitwarden.e2e.seeder + +import kotlinx.serialization.Serializable + +/** + * SeederApi response envelope. + * + * @property mangleMap Original request values mapped to the unique values the Seeder stored. + * @property result The scene's result. + */ +@Serializable +data class SeedResponse( + val mangleMap: Map = emptyMap(), + val result: R, +) + +/** + * Arguments for the `SingleUserScene` template. + * + * @property email Email before mangling. + * @property password Master password. + * @property emailVerified Whether the email is pre-verified. + * @property selfHosted Whether the user is created for a self-hosted server. + * @property kdfIterations PBKDF2 iterations for the master key. + */ +@Serializable +data class SingleUserSceneArguments( + val email: String, + val password: String, + val emailVerified: Boolean, + val selfHosted: Boolean, + val kdfIterations: Int, +) + +/** + * Result of the `SingleUserScene` template. + * + * @property userId The new user's ID. + * @property decryptedKeyB64 The user's symmetric key, used to seed their vault items. + */ +@Serializable +data class SingleUserSceneResult( + val userId: String, + val decryptedKeyB64: String, +) + +/** + * Arguments for the `UserLoginCipherScene` template. The Seeder encrypts the values server-side. + * + * @property userId Owner of the item. + * @property userKeyB64 The owner's symmetric key. + * @property name Item name. + * @property username Login username. + * @property password Login password. + * @property uri Login URI. + */ +@Serializable +data class UserLoginCipherSceneArguments( + val userId: String, + val userKeyB64: String, + val name: String, + val username: String, + val password: String, + val uri: String, +) + +/** + * Result of the `UserLoginCipherScene` template. + * + * @property cipherId The new item's ID. + */ +@Serializable +data class UserLoginCipherSceneResult( + val cipherId: String, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9367d898d4..659754765c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,11 @@ androidxNavigation = "2.9.8" androidxRoom3 = "3.0.3" androidxSecurityCrypto = "1.1.0" androidxSplash = "1.2.0" +androidxTestEspresso = "3.7.0" +androidxTestExtJunit = "1.3.0" +androidxTestOrchestrator = "1.6.1" +androidxTestRunner = "1.7.0" +androidxTestServices = "1.6.0" androidxWork = "2.12.0" bitwardenSdk = "4.0.0-9737-d9119f7a" coil = "3.6.3" @@ -95,6 +100,11 @@ androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.re androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "androidxRoom3" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidxSecurityCrypto" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxSplash" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidxTestOrchestrator" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +androidx-test-services = { module = "androidx.test.services:test-services", version.ref = "androidxTestServices" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" } bitwarden-sdk = { module = "com.bitwarden:sdk-android", version.ref = "bitwardenSdk" } coil-coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }