mirror of
https://github.com/bitwarden/android.git
synced 2026-11-09 10:34:18 -06:00
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.
This commit is contained in:
@@ -71,6 +71,8 @@ configure<ApplicationExtension> {
|
||||
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<ApplicationExtension> {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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 <server url> \
|
||||
-e BW_SEEDER_URL <seeder url> \
|
||||
-e BW_SEEDER_TOKEN <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.
|
||||
@@ -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<MainActivity>(
|
||||
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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 <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")
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <A, R> seed(
|
||||
template: String,
|
||||
arguments: A,
|
||||
argumentsSerializer: KSerializer<A>,
|
||||
resultSerializer: KSerializer<R>,
|
||||
): SeedResponse<R> {
|
||||
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
|
||||
@@ -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<R>(
|
||||
val mangleMap: Map<String, String> = 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,
|
||||
)
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user