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:
Corbet Weller
2026-11-04 17:42:56 -05:00
parent 512650c477
commit ec135795a5
17 changed files with 795 additions and 0 deletions
+15
View File
@@ -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 {
+55
View File
@@ -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")
@@ -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,
)
+10
View File
@@ -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" }