mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
[PM-28086] Add testharness for Credential Manager and Autofill testing (#6159)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
53e358d7b3
commit
a395f28eba
@ -27,6 +27,7 @@ dependencies {
|
||||
kover(project(":cxf"))
|
||||
kover(project(":data"))
|
||||
kover(project(":network"))
|
||||
kover(project(":testharness"))
|
||||
kover(project(":ui"))
|
||||
}
|
||||
|
||||
@ -42,6 +43,7 @@ detekt {
|
||||
"cxf/src",
|
||||
"data/src",
|
||||
"network/src",
|
||||
"testharness/src",
|
||||
"ui/src",
|
||||
)
|
||||
}
|
||||
|
||||
@ -56,5 +56,6 @@ include(
|
||||
":cxf",
|
||||
":data",
|
||||
":network",
|
||||
":testharness",
|
||||
":ui",
|
||||
)
|
||||
|
||||
9
testharness/.gitignore
vendored
Normal file
9
testharness/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
/build
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
79
testharness/README.md
Normal file
79
testharness/README.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Credential Manager Test Harness
|
||||
|
||||
## Purpose
|
||||
|
||||
This standalone application serves as a test client for validating the Bitwarden Android credential provider implementation. It uses the Android CredentialManager APIs to request credential operations and verify that the `:app` module responds correctly as a credential provider.
|
||||
|
||||
Future iterations will introduce validation for the Android Autofill Framework.
|
||||
|
||||
## Features
|
||||
|
||||
- **Password Creation**: Test `CreatePasswordRequest` flow through CredentialManager
|
||||
- **Password Retrieval**: Test `GetPasswordOption` flow through CredentialManager
|
||||
- **Passkey Creation**: Test `CreatePublicKeyCredentialRequest` flow through CredentialManager
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android device or emulator running API 28+
|
||||
- Bitwarden app (`:app`) installed and configured as a credential provider
|
||||
- Google Play Services (for API 28-33 compatibility)
|
||||
|
||||
## Usage
|
||||
|
||||
### Setup
|
||||
|
||||
1. Build and install the main Bitwarden app (`:app`)
|
||||
2. Configure Bitwarden as a credential provider in system settings:
|
||||
- Settings → Passwords & accounts → Credential Manager
|
||||
- Enable Bitwarden as a provider
|
||||
3. Build and install the test harness:
|
||||
```bash
|
||||
./gradlew :testharness:installDebug
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
1. Launch "Credential Manager Test Harness" app
|
||||
2. Select a credential operation type (Password Create, Password Get, or Passkey Create)
|
||||
3. Fill in required fields:
|
||||
- **Password Create**: Username, Password, Origin (optional)
|
||||
- **Password Get**: No inputs required
|
||||
- **Passkey Create**: Username, Relying Party ID, Origin
|
||||
- **Passkey Get**: Relying Party ID, Origin
|
||||
4. Tap "Execute" button
|
||||
5. System credential picker should appear with Bitwarden as an option
|
||||
6. Select Bitwarden and follow the flow
|
||||
7. Result will be displayed in the app
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **API 28-33 compatibility**: Requires Google Play Services for CredentialManager APIs on older Android versions.
|
||||
2. Passkey operations must be performed as a Privileged App due to security measures built in `:app`.
|
||||
|
||||
## Architecture
|
||||
|
||||
This module follows the same architectural patterns as the main Bitwarden app:
|
||||
- **MVVM + UDF**: `BaseViewModel` with State/Action/Event pattern
|
||||
- **Hilt DI**: Dependency injection throughout
|
||||
- **Compose UI**: Modern declarative UI with Material 3
|
||||
- **Result handling**: No exceptions, sealed result classes
|
||||
|
||||
## Testing
|
||||
|
||||
Run unit tests:
|
||||
```bash
|
||||
./gradlew :testharness:test
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Check Logcat for detailed error messages:
|
||||
```bash
|
||||
adb logcat | grep -E "CredentialManager|Bitwarden|TestHarness"
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Android Credential Manager Documentation](https://developer.android.com/identity/credential-manager)
|
||||
- [Bitwarden Android Architecture](../docs/ARCHITECTURE.md)
|
||||
- [Passkey Registration Research](../PASSKEY_REGISTRATION_RESEARCH_REPORT.md)
|
||||
117
testharness/build.gradle.kts
Normal file
117
testharness/build.gradle.kts
Normal file
@ -0,0 +1,117 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose.compiler)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.bitwarden.testharness"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.bitwarden.testharness"
|
||||
// API 28 - CredentialManager with Play Services support
|
||||
minSdk = libs.versions.minSdkBwa.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".dev"
|
||||
isDebuggable = true
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
// Required for Android framework classes in unit tests
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility(libs.versions.jvmTarget.get())
|
||||
targetCompatibility(libs.versions.jvmTarget.get())
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Internal modules
|
||||
implementation(project(":annotation"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":ui"))
|
||||
|
||||
// AndroidX Credentials - PRIMARY DEPENDENCY
|
||||
implementation(libs.androidx.credentials)
|
||||
|
||||
// Hilt DI
|
||||
implementation(libs.google.hilt.android)
|
||||
ksp(libs.google.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// Compose UI
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
|
||||
// Kotlin essentials
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.androidx.compose.ui.test)
|
||||
testImplementation(libs.google.hilt.android.testing)
|
||||
testImplementation(platform(libs.junit.bom))
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testImplementation(libs.junit.vintage)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk.mockk)
|
||||
// Robolectric reserved for future Android framework tests if needed
|
||||
testImplementation(libs.robolectric.robolectric)
|
||||
testImplementation(libs.square.turbine)
|
||||
|
||||
testImplementation(testFixtures(project(":ui")))
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "2g"
|
||||
maxParallelForks = Runtime.getRuntime().availableProcessors()
|
||||
}
|
||||
}
|
||||
23
testharness/proguard-rules.pro
vendored
Normal file
23
testharness/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# CredentialManager classes
|
||||
-keep class androidx.credentials.** { *; }
|
||||
-keep interface androidx.credentials.** { *; }
|
||||
|
||||
# Hilt
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
|
||||
# Kotlinx Serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class com.bitwarden.testharness.**$$serializer { *; }
|
||||
-keepclassmembers class com.bitwarden.testharness.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.bitwarden.testharness.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
30
testharness/src/main/AndroidManifest.xml
Normal file
30
testharness/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.CREDENTIAL_MANAGER_SET_ORIGIN" />
|
||||
|
||||
<application
|
||||
android:name=".TestHarnessApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||
|
||||
<!-- Digital Asset Links for Credential Manager (empty for test consumer) -->
|
||||
<meta-data
|
||||
android:name="asset_statements"
|
||||
android:resource="@string/asset_statements" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -0,0 +1,58 @@
|
||||
package com.bitwarden.testharness
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.bitwarden.testharness.ui.platform.feature.rootnav.RootNavScreen
|
||||
import com.bitwarden.testharness.ui.platform.feature.rootnav.RootNavigationRoute
|
||||
import com.bitwarden.testharness.ui.platform.feature.rootnav.rootNavDestination
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.platform.util.setupEdgeToEdge
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Primary entry point for the Credential Manager test harness application.
|
||||
*
|
||||
* Delegates navigation to [RootNavScreen] following the same pattern as @app and @authenticator
|
||||
* modules. Handles Activity-level concerns like theme and splash screen.
|
||||
*
|
||||
* The root navigation is managed by [RootNavScreen] which orchestrates all test screen flows:
|
||||
* - Landing screen with test category selection (Autofill, Credential Manager)
|
||||
* - Individual test screens for each Credential Manager API operation
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val mainViewModel: MainViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
var shouldShowSplashScreen = true
|
||||
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setupEdgeToEdge(appThemeFlow = mainViewModel.stateFlow.map { it.theme })
|
||||
|
||||
setContent {
|
||||
val navController = rememberNavController()
|
||||
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
BitwardenTheme(
|
||||
theme = state.theme,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = RootNavigationRoute,
|
||||
) {
|
||||
rootNavDestination { shouldShowSplashScreen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.bitwarden.testharness
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel that manages Activity-level state for the test harness.
|
||||
*
|
||||
* Handles theme and other cross-cutting concerns at the Activity level.
|
||||
* This follows the pattern from the main app's MainActivity/MainViewModel.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(
|
||||
// Minimal dependencies for test harness
|
||||
// Could add SettingsRepository if theme persistence needed
|
||||
) : BaseViewModel<MainState, MainEvent, MainAction>(
|
||||
initialState = MainState(
|
||||
theme = AppTheme.DEFAULT,
|
||||
),
|
||||
) {
|
||||
|
||||
override fun handleAction(action: MainAction) {
|
||||
// Minimal actions for test harness
|
||||
// Could handle theme changes if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state for the [MainActivity].
|
||||
*
|
||||
* @property theme The current app theme.
|
||||
*/
|
||||
@Parcelize
|
||||
data class MainState(
|
||||
val theme: AppTheme,
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Models actions for the [MainActivity].
|
||||
*
|
||||
* Currently empty but reserved for future Activity-level actions.
|
||||
*/
|
||||
sealed class MainAction
|
||||
|
||||
/**
|
||||
* Models events emitted by the [MainActivity].
|
||||
*
|
||||
* Currently empty but reserved for future Activity-level events such as
|
||||
* theme updates or Activity recreation requirements.
|
||||
*/
|
||||
sealed class MainEvent
|
||||
@ -0,0 +1,14 @@
|
||||
package com.bitwarden.testharness
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
/**
|
||||
* Application class for the Credential Manager test harness.
|
||||
*
|
||||
* This test application validates the credential provider implementation in the main
|
||||
* Bitwarden app by acting as a client application that requests credential operations
|
||||
* through the Android CredentialManager API.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class TestHarnessApplication : Application()
|
||||
@ -0,0 +1,80 @@
|
||||
package com.bitwarden.testharness.data.manager
|
||||
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
|
||||
/**
|
||||
* Manager for testing credential operations through the Android CredentialManager API.
|
||||
*
|
||||
* This manager wraps the CredentialManager API to test credential provider implementations.
|
||||
*/
|
||||
interface CredentialTestManager {
|
||||
/**
|
||||
* Test password creation through CredentialManager.
|
||||
*
|
||||
* @param username The username/identifier for the password.
|
||||
* @param password The password value.
|
||||
* @param origin Optional origin/domain for the credential (e.g., "https://example.com").
|
||||
* @return Result indicating success, error, or cancellation.
|
||||
*/
|
||||
suspend fun createPassword(
|
||||
username: String,
|
||||
password: String,
|
||||
origin: String?,
|
||||
): CredentialTestResult
|
||||
|
||||
/**
|
||||
* Test password retrieval through CredentialManager.
|
||||
*
|
||||
* @return Result indicating success with credential data, error, or cancellation.
|
||||
*/
|
||||
suspend fun getPassword(): CredentialTestResult
|
||||
|
||||
/**
|
||||
* Test passkey creation through CredentialManager.
|
||||
*
|
||||
* @param username The username/identifier for the passkey.
|
||||
* @param rpId The Relying Party ID (domain).
|
||||
* @param origin Optional origin/domain for privileged app simulation
|
||||
* (e.g., "https://example.com"). Used to simulate browser or password manager apps making
|
||||
* requests on behalf of websites. Requires CREDENTIAL_MANAGER_SET_ORIGIN permission.
|
||||
* @return Result indicating success, error, or cancellation.
|
||||
*/
|
||||
suspend fun createPasskey(
|
||||
username: String,
|
||||
rpId: String,
|
||||
origin: String? = null,
|
||||
): CredentialTestResult
|
||||
|
||||
/**
|
||||
* Test passkey authentication through CredentialManager.
|
||||
*
|
||||
* @param rpId The Relying Party ID (domain) for the passkey request.
|
||||
* @param origin Optional origin/domain for privileged app simulation
|
||||
* (e.g., "https://example.com"). Used to simulate browser or password manager apps making
|
||||
* requests on behalf of websites. Requires CREDENTIAL_MANAGER_SET_ORIGIN permission.
|
||||
* @return Result indicating success with credential data, error, or cancellation.
|
||||
*/
|
||||
suspend fun getPasskey(
|
||||
rpId: String,
|
||||
origin: String? = null,
|
||||
): CredentialTestResult
|
||||
|
||||
/**
|
||||
* Test combined password and passkey retrieval through CredentialManager.
|
||||
*
|
||||
* This method includes both GetPasswordOption and GetPublicKeyCredentialOption in a single
|
||||
* GetCredentialRequest, allowing the user to choose between saved passwords and passkeys
|
||||
* from the system credential picker.
|
||||
*
|
||||
* @param rpId The Relying Party ID (domain) for the passkey request.
|
||||
* @param origin Optional origin/domain for privileged app simulation
|
||||
* (e.g., "https://example.com"). Used to simulate browser or password manager apps making
|
||||
* requests on behalf of websites. Requires CREDENTIAL_MANAGER_SET_ORIGIN permission.
|
||||
* @return Result indicating success with credential data (password or passkey), error, or
|
||||
* cancellation.
|
||||
*/
|
||||
suspend fun getPasswordOrPasskey(
|
||||
rpId: String,
|
||||
origin: String? = null,
|
||||
): CredentialTestResult
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
package com.bitwarden.testharness.data.manager
|
||||
|
||||
import android.app.Application
|
||||
import androidx.credentials.CreatePasswordRequest
|
||||
import androidx.credentials.CreatePasswordResponse
|
||||
import androidx.credentials.CreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.CreatePublicKeyCredentialResponse
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetPasswordOption
|
||||
import androidx.credentials.GetPublicKeyCredentialOption
|
||||
import androidx.credentials.PasswordCredential
|
||||
import androidx.credentials.PublicKeyCredential
|
||||
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
||||
import androidx.credentials.exceptions.CreateCredentialException
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.testharness.data.util.WebAuthnJsonBuilder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default implementation of [CredentialTestManager].
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialTestManagerImpl @Inject constructor(
|
||||
private val application: Application,
|
||||
private val credentialManager: CredentialManager,
|
||||
) : CredentialTestManager {
|
||||
|
||||
override suspend fun createPassword(
|
||||
username: String,
|
||||
password: String,
|
||||
origin: String?,
|
||||
): CredentialTestResult {
|
||||
val request = CreatePasswordRequest(
|
||||
id = username,
|
||||
password = password,
|
||||
origin = origin,
|
||||
)
|
||||
|
||||
val result = try {
|
||||
credentialManager.createCredential(
|
||||
context = application,
|
||||
request = request,
|
||||
)
|
||||
} catch (_: CreateCredentialCancellationException) {
|
||||
return CredentialTestResult.Cancelled
|
||||
} catch (e: CreateCredentialException) {
|
||||
return CredentialTestResult.Error(exception = e)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is CreatePasswordResponse -> {
|
||||
CredentialTestResult.Success(
|
||||
data = "Username: $username\nOrigin: ${origin ?: "null"}",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CredentialTestResult.Error(
|
||||
exception = IllegalStateException(
|
||||
"Unexpected response type: ${result::class.simpleName}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPassword(): CredentialTestResult {
|
||||
val request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPasswordOption()),
|
||||
)
|
||||
|
||||
val result = try {
|
||||
credentialManager.getCredential(
|
||||
context = application,
|
||||
request = request,
|
||||
)
|
||||
} catch (_: GetCredentialCancellationException) {
|
||||
return CredentialTestResult.Cancelled
|
||||
} catch (e: GetCredentialException) {
|
||||
return CredentialTestResult.Error(exception = e)
|
||||
}
|
||||
|
||||
return when (val credential = result.credential) {
|
||||
is PasswordCredential -> {
|
||||
CredentialTestResult.Success(
|
||||
data = "Username: ${credential.id}\nPassword: ${credential.password}",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CredentialTestResult.Error(
|
||||
exception = IllegalStateException(
|
||||
"Unexpected credential type: ${credential::class.simpleName}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createPasskey(
|
||||
username: String,
|
||||
rpId: String,
|
||||
origin: String?,
|
||||
): CredentialTestResult {
|
||||
// Build minimal passkey creation request JSON
|
||||
val requestJson = WebAuthnJsonBuilder.buildPasskeyCreationJson(username, rpId)
|
||||
|
||||
// Conditionally include origin parameter for privileged app simulation
|
||||
val request = if (origin.isNullOrBlank()) {
|
||||
CreatePublicKeyCredentialRequest(
|
||||
requestJson = requestJson,
|
||||
)
|
||||
} else {
|
||||
CreatePublicKeyCredentialRequest(
|
||||
requestJson = requestJson,
|
||||
origin = origin,
|
||||
)
|
||||
}
|
||||
|
||||
val result = try {
|
||||
credentialManager.createCredential(
|
||||
context = application,
|
||||
request = request,
|
||||
)
|
||||
} catch (_: CreateCredentialCancellationException) {
|
||||
return CredentialTestResult.Cancelled
|
||||
} catch (e: CreateCredentialException) {
|
||||
return CredentialTestResult.Error(exception = e)
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is CreatePublicKeyCredentialResponse -> {
|
||||
CredentialTestResult.Success(
|
||||
data = "RP ID: $rpId\nOrigin: ${origin ?: "null"}\n\n" +
|
||||
result.registrationResponseJson,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CredentialTestResult.Error(
|
||||
exception = IllegalStateException(
|
||||
"Unexpected response type: ${result::class.simpleName}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPasskey(
|
||||
rpId: String,
|
||||
origin: String?,
|
||||
): CredentialTestResult {
|
||||
// Build minimal passkey authentication request JSON
|
||||
val requestJson = WebAuthnJsonBuilder.buildPasskeyAuthenticationJson(rpId)
|
||||
|
||||
val option = GetPublicKeyCredentialOption(
|
||||
requestJson = requestJson,
|
||||
)
|
||||
|
||||
val request = GetCredentialRequest(
|
||||
credentialOptions = listOf(option),
|
||||
origin = origin,
|
||||
)
|
||||
|
||||
val result = try {
|
||||
credentialManager.getCredential(
|
||||
context = application,
|
||||
request = request,
|
||||
)
|
||||
} catch (_: GetCredentialCancellationException) {
|
||||
return CredentialTestResult.Cancelled
|
||||
} catch (e: GetCredentialException) {
|
||||
return CredentialTestResult.Error(exception = e)
|
||||
}
|
||||
|
||||
return when (val credential = result.credential) {
|
||||
is PublicKeyCredential -> {
|
||||
CredentialTestResult.Success(
|
||||
data = "RP ID: $rpId\nOrigin: ${origin ?: "null"}\n\n" +
|
||||
credential.authenticationResponseJson,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CredentialTestResult.Error(
|
||||
exception = IllegalStateException(
|
||||
"Unexpected credential type: ${credential::class.simpleName}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPasswordOrPasskey(
|
||||
rpId: String,
|
||||
origin: String?,
|
||||
): CredentialTestResult {
|
||||
// Build passkey authentication request JSON
|
||||
val requestJson = WebAuthnJsonBuilder.buildPasskeyAuthenticationJson(rpId)
|
||||
|
||||
// Create request with both password and passkey options
|
||||
// Conditionally include origin parameter for privileged app simulation
|
||||
val request = if (origin.isNullOrBlank()) {
|
||||
GetCredentialRequest(
|
||||
credentialOptions = listOf(
|
||||
GetPasswordOption(),
|
||||
GetPublicKeyCredentialOption(requestJson = requestJson),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
GetCredentialRequest(
|
||||
credentialOptions = listOf(
|
||||
GetPasswordOption(),
|
||||
GetPublicKeyCredentialOption(requestJson = requestJson),
|
||||
),
|
||||
origin = origin,
|
||||
)
|
||||
}
|
||||
|
||||
val result = try {
|
||||
credentialManager.getCredential(
|
||||
context = application,
|
||||
request = request,
|
||||
)
|
||||
} catch (_: GetCredentialCancellationException) {
|
||||
return CredentialTestResult.Cancelled
|
||||
} catch (e: GetCredentialException) {
|
||||
return CredentialTestResult.Error(exception = e)
|
||||
}
|
||||
|
||||
return when (val credential = result.credential) {
|
||||
is PasswordCredential -> {
|
||||
CredentialTestResult.Success(
|
||||
data = "Type: PASSWORD\n" +
|
||||
"Username: ${credential.id}\n" +
|
||||
"Password: ${credential.password}\n" +
|
||||
"Origin: ${origin ?: "null"}",
|
||||
)
|
||||
}
|
||||
|
||||
is PublicKeyCredential -> {
|
||||
CredentialTestResult.Success(
|
||||
data = "Type: PASSKEY\n" +
|
||||
"Origin: ${origin ?: "null"}\n" +
|
||||
"Response JSON:\n${credential.authenticationResponseJson}",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CredentialTestResult.Error(
|
||||
exception = IllegalStateException(
|
||||
"Unexpected credential type: ${credential::class.simpleName}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bitwarden.testharness.data.manager.di
|
||||
|
||||
import android.app.Application
|
||||
import androidx.credentials.CredentialManager
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManagerImpl
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides dependencies for credential test managers.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CredentialTestManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialManager(
|
||||
application: Application,
|
||||
): CredentialManager = CredentialManager.create(application.applicationContext)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialTestManager(
|
||||
application: Application,
|
||||
credentialManager: CredentialManager,
|
||||
): CredentialTestManager = CredentialTestManagerImpl(
|
||||
application = application,
|
||||
credentialManager = credentialManager,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.bitwarden.testharness.data.model
|
||||
|
||||
/**
|
||||
* Result of a credential operation test.
|
||||
*/
|
||||
sealed class CredentialTestResult {
|
||||
/**
|
||||
* Credential operation completed successfully.
|
||||
*
|
||||
* @property data Structured credential response data (e.g., username, origin, JSON response).
|
||||
*/
|
||||
data class Success(
|
||||
val data: String? = null,
|
||||
) : CredentialTestResult()
|
||||
|
||||
/**
|
||||
* Credential operation failed with an error.
|
||||
*
|
||||
* @property exception The underlying exception that caused the failure.
|
||||
*/
|
||||
data class Error(
|
||||
val exception: Throwable? = null,
|
||||
) : CredentialTestResult()
|
||||
|
||||
/**
|
||||
* User cancelled the credential operation.
|
||||
*/
|
||||
data object Cancelled : CredentialTestResult()
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package com.bitwarden.testharness.data.util
|
||||
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
|
||||
private const val CHALLENGE_SEED_SIZE = 32
|
||||
|
||||
/**
|
||||
* Builder for WebAuthn JSON structures required by the Credential Manager API.
|
||||
*
|
||||
* Generates minimal valid JSON for passkey registration and authentication flows
|
||||
* following the WebAuthn specification.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
object WebAuthnJsonBuilder {
|
||||
|
||||
/**
|
||||
* Build a minimal valid WebAuthn registration request JSON.
|
||||
*
|
||||
* This follows the WebAuthn specification for PublicKeyCredentialCreationOptions.
|
||||
*
|
||||
* **WARNING: TEST HARNESS ONLY** - This implementation uses simplified challenge
|
||||
* generation suitable for testing. Production implementations should use a secure
|
||||
* backend service to generate and validate challenges.
|
||||
*
|
||||
* @param username The username for the passkey.
|
||||
* @param rpId The Relying Party ID.
|
||||
* @return JSON string for passkey creation request.
|
||||
*/
|
||||
fun buildPasskeyCreationJson(username: String, rpId: String): String {
|
||||
// Generate random challenge (base64url encoded)
|
||||
val challenge = Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(SecureRandom().generateSeed(CHALLENGE_SEED_SIZE))
|
||||
|
||||
// Generate random user ID (base64url encoded)
|
||||
val userId = Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(username.toByteArray())
|
||||
|
||||
return """
|
||||
{
|
||||
"challenge": "$challenge",
|
||||
"rp": {
|
||||
"name": "Test Harness",
|
||||
"id": "$rpId"
|
||||
},
|
||||
"user": {
|
||||
"id": "$userId",
|
||||
"name": "$username",
|
||||
"displayName": "$username"
|
||||
},
|
||||
"pubKeyCredParams": [
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -7
|
||||
},
|
||||
{
|
||||
"type": "public-key",
|
||||
"alg": -257
|
||||
}
|
||||
],
|
||||
"timeout": 60000,
|
||||
"attestation": "none",
|
||||
"authenticatorSelection": {
|
||||
"authenticatorAttachment": "platform",
|
||||
"residentKey": "required",
|
||||
"requireResidentKey": true,
|
||||
"userVerification": "required"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal valid WebAuthn authentication request JSON.
|
||||
*
|
||||
* This follows the WebAuthn specification for PublicKeyCredentialRequestOptions.
|
||||
*
|
||||
* **WARNING: TEST HARNESS ONLY** - This implementation uses simplified challenge
|
||||
* generation suitable for testing. Production implementations should use a secure
|
||||
* backend service to generate and validate challenges.
|
||||
*
|
||||
* @param rpId The Relying Party ID.
|
||||
* @return JSON string for passkey authentication request.
|
||||
*/
|
||||
fun buildPasskeyAuthenticationJson(rpId: String): String {
|
||||
// Generate random challenge (base64url encoded)
|
||||
val challenge = Base64.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(SecureRandom().generateSeed(CHALLENGE_SEED_SIZE))
|
||||
|
||||
return """
|
||||
{
|
||||
"challenge": "$challenge",
|
||||
"rpId": "$rpId",
|
||||
"userVerification": "preferred"
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.autofill
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.navigation
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Autofill graph route - serves as the parent for all Autofill test flows.
|
||||
*/
|
||||
@Serializable
|
||||
data object AutofillGraphRoute
|
||||
|
||||
/**
|
||||
* Autofill test placeholder screen - the start destination of the graph.
|
||||
*/
|
||||
@Serializable
|
||||
data object AutofillPlaceholderRoute
|
||||
|
||||
/**
|
||||
* Add Autofill nav graph to the root nav graph.
|
||||
*
|
||||
* This graph contains the placeholder screen and can be expanded with
|
||||
* additional autofill test screens as needed.
|
||||
*/
|
||||
fun NavGraphBuilder.autofillGraph(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
navigation<AutofillGraphRoute>(
|
||||
startDestination = AutofillPlaceholderRoute,
|
||||
) {
|
||||
autofillPlaceholderDestination(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.autofillPlaceholderDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithRootPushTransitions<AutofillPlaceholderRoute> {
|
||||
AutofillPlaceholderScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Autofill graph.
|
||||
*/
|
||||
fun NavController.navigateToAutofillGraph(navOptions: NavOptions? = null) {
|
||||
navigate(route = AutofillGraphRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.autofill
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
/**
|
||||
* Placeholder screen for Autofill testing (not yet implemented).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AutofillPlaceholderScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: AutofillPlaceholderViewModel = hiltViewModel(),
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
AutofillPlaceholderEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.autofill_testing),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = {
|
||||
viewModel.trySendAction(AutofillPlaceholderAction.BackClick)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.autofill_coming_soon),
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.autofill
|
||||
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for Autofill Placeholder screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AutofillPlaceholderViewModel @Inject constructor() :
|
||||
BaseViewModel<Unit, AutofillPlaceholderEvent, AutofillPlaceholderAction>(
|
||||
initialState = Unit,
|
||||
) {
|
||||
|
||||
override fun handleAction(action: AutofillPlaceholderAction) {
|
||||
when (action) {
|
||||
AutofillPlaceholderAction.BackClick -> handleBackClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(AutofillPlaceholderEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events for Autofill Placeholder screen.
|
||||
*/
|
||||
sealed class AutofillPlaceholderEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : AutofillPlaceholderEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Autofill Placeholder screen.
|
||||
*/
|
||||
sealed class AutofillPlaceholderAction {
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : AutofillPlaceholderAction()
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpasskey
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Create passkey test screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object CreatePasskeyRoute
|
||||
|
||||
/**
|
||||
* Add Create Passkey destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.createPasskeyDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<CreatePasskeyRoute> {
|
||||
CreatePasskeyScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Create Passkey test screen.
|
||||
*/
|
||||
fun NavController.navigateToCreatePasskey(navOptions: NavOptions? = null) {
|
||||
navigate(route = CreatePasskeyRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpasskey
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* Create Passkey test screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreatePasskeyScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: CreatePasskeyViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
CreatePasskeyEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
CreatePasskeyScreenContent(
|
||||
state = state,
|
||||
onBackClick = { viewModel.trySendAction(CreatePasskeyAction.BackClick) },
|
||||
onUsernameChange = { viewModel.trySendAction(CreatePasskeyAction.UsernameChanged(it)) },
|
||||
onRpIdChange = { viewModel.trySendAction(CreatePasskeyAction.RpIdChanged(it)) },
|
||||
onOriginChange = { viewModel.trySendAction(CreatePasskeyAction.OriginChanged(it)) },
|
||||
onExecuteClick = { viewModel.trySendAction(CreatePasskeyAction.ExecuteClick) },
|
||||
onClearResultClick = { viewModel.trySendAction(CreatePasskeyAction.ClearResultClick) },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CreatePasskeyScreenContent(
|
||||
state: CreatePasskeyState,
|
||||
onBackClick: () -> Unit,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onRpIdChange: (String) -> Unit,
|
||||
onOriginChange: (String) -> Unit,
|
||||
onExecuteClick: () -> Unit,
|
||||
onClearResultClick: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.create_passkey_title),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = onBackClick,
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(state = rememberScrollState())
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.imePadding(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.username),
|
||||
value = state.username,
|
||||
onValueChange = onUsernameChange,
|
||||
cardStyle = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.relying_party_id),
|
||||
value = state.rpId,
|
||||
onValueChange = onRpIdChange,
|
||||
placeholder = stringResource(R.string.rp_id_hint),
|
||||
cardStyle = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.origin_optional),
|
||||
value = state.origin,
|
||||
onValueChange = onOriginChange,
|
||||
placeholder = stringResource(R.string.origin_hint),
|
||||
cardStyle = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(R.string.execute),
|
||||
onClick = onExecuteClick,
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(BitwardenString.clear),
|
||||
onClick = onClearResultClick,
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.result),
|
||||
value = state.resultText,
|
||||
onValueChange = { },
|
||||
cardStyle = null,
|
||||
readOnly = true,
|
||||
singleLine = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CreatePasskeyScreenPreview() {
|
||||
CreatePasskeyScreenContent(
|
||||
state = CreatePasskeyState(
|
||||
username = "user@bitwarden.com",
|
||||
rpId = "passkeys.example.com",
|
||||
origin = "https://passkeys.example.com",
|
||||
isLoading = false,
|
||||
resultText = "This is the result of the operation.",
|
||||
),
|
||||
onBackClick = {},
|
||||
onUsernameChange = {},
|
||||
onRpIdChange = {},
|
||||
onOriginChange = {},
|
||||
onExecuteClick = {},
|
||||
onClearResultClick = {},
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpasskey
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val MAX_STACK_TRACE_LINES = 5
|
||||
private const val RESULT_SEPARATOR_LENGTH = 40
|
||||
|
||||
/**
|
||||
* ViewModel for Create Passkey test screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CreatePasskeyViewModel @Inject constructor(
|
||||
private val credentialTestManager: CredentialTestManager,
|
||||
private val clock: Clock,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<CreatePasskeyState, CreatePasskeyEvent, CreatePasskeyAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: CreatePasskeyState(),
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: CreatePasskeyAction) {
|
||||
when (action) {
|
||||
is CreatePasskeyAction.UsernameChanged -> handleUsernameChanged(action)
|
||||
is CreatePasskeyAction.RpIdChanged -> handleRpIdChanged(action)
|
||||
is CreatePasskeyAction.OriginChanged -> handleOriginChanged(action)
|
||||
CreatePasskeyAction.ExecuteClick -> handleExecuteClick()
|
||||
CreatePasskeyAction.ClearResultClick -> handleClearResultClick()
|
||||
CreatePasskeyAction.BackClick -> handleBackClick()
|
||||
is CreatePasskeyAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(CreatePasskeyEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleUsernameChanged(action: CreatePasskeyAction.UsernameChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(username = action.username)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRpIdChanged(action: CreatePasskeyAction.RpIdChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(rpId = action.rpId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOriginChanged(action: CreatePasskeyAction.OriginChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(origin = action.origin)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExecuteClick() {
|
||||
val currentState = stateFlow.value
|
||||
val username = currentState.username
|
||||
val rpId = currentState.rpId
|
||||
val origin = currentState.origin.takeIf { it.isNotBlank() }
|
||||
|
||||
if (username.isBlank() || rpId.isBlank()) {
|
||||
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
|
||||
"Username and Relying Party ID are required\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
resultText = it.resultText + errorMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startMessage = "\n${timestamp()} ⏳ Creating passkey credential...\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
resultText = it.resultText + startMessage,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = credentialTestManager.createPasskey(
|
||||
username = username,
|
||||
rpId = rpId,
|
||||
origin = origin,
|
||||
)
|
||||
sendAction(CreatePasskeyAction.Internal.CredentialResultReceived(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearResultClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(resultText = "Result cleared.\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: CreatePasskeyAction.Internal) {
|
||||
when (action) {
|
||||
is CreatePasskeyAction.Internal.CredentialResultReceived -> {
|
||||
handleCredentialResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCredentialResultReceived(
|
||||
action: CreatePasskeyAction.Internal.CredentialResultReceived,
|
||||
) {
|
||||
val resultMessage = when (val result = action.result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
buildString {
|
||||
append("${timestamp()} ✅ SUCCESS: Passkey created successfully\n")
|
||||
if (result.data != null) {
|
||||
append("\n📋 Response Data:\n${result.data}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is CredentialTestResult.Error -> {
|
||||
buildString {
|
||||
val errorMessage = result.exception?.message ?: "Unknown error"
|
||||
append("${timestamp()} ❌ ERROR: Failed to create passkey: $errorMessage\n")
|
||||
if (result.exception != null) {
|
||||
append("\n🔍 Exception:\n${result.exception}\n")
|
||||
append("\nStack trace:\n")
|
||||
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
|
||||
append(" at $it\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CredentialTestResult.Cancelled -> {
|
||||
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
|
||||
}
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
resultText = it.resultText + resultMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timestamp(): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
return clock.instant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(formatter)
|
||||
.let { "[$it]" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Create Passkey screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CreatePasskeyState(
|
||||
val username: String = "",
|
||||
val rpId: String = "",
|
||||
val origin: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val resultText: String = "Ready to create passkey credential.\n\n" +
|
||||
"Enter username and Relying Party ID, then click Execute.",
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Events for Create Passkey screen.
|
||||
*/
|
||||
sealed class CreatePasskeyEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : CreatePasskeyEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Create Passkey screen.
|
||||
*/
|
||||
sealed class CreatePasskeyAction {
|
||||
/**
|
||||
* Username input value changed.
|
||||
*/
|
||||
data class UsernameChanged(val username: String) : CreatePasskeyAction()
|
||||
|
||||
/**
|
||||
* Relying Party ID input value changed.
|
||||
*/
|
||||
data class RpIdChanged(val rpId: String) : CreatePasskeyAction()
|
||||
|
||||
/**
|
||||
* Origin input value changed.
|
||||
*/
|
||||
data class OriginChanged(val origin: String) : CreatePasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked execute button to create passkey credential.
|
||||
*/
|
||||
data object ExecuteClick : CreatePasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked clear button to reset results.
|
||||
*/
|
||||
data object ClearResultClick : CreatePasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : CreatePasskeyAction()
|
||||
|
||||
/**
|
||||
* Internal actions for Create Passkey screen.
|
||||
*/
|
||||
sealed class Internal : CreatePasskeyAction() {
|
||||
/**
|
||||
* Credential operation result received.
|
||||
*/
|
||||
data class CredentialResultReceived(
|
||||
val result: CredentialTestResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpassword
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Create password test screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object CreatePasswordRoute
|
||||
|
||||
/**
|
||||
* Add Create Password destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.createPasswordDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<CreatePasswordRoute> {
|
||||
CreatePasswordScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Create Password test screen.
|
||||
*/
|
||||
fun NavController.navigateToCreatePassword(navOptions: NavOptions? = null) {
|
||||
navigate(route = CreatePasswordRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpassword
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* Create Password test screen.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreatePasswordScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: CreatePasswordViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
CreatePasswordEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.create_password_title),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(CreatePasswordAction.BackClick) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.username),
|
||||
value = state.username,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(CreatePasswordAction.UsernameChanged(it)) }
|
||||
},
|
||||
cardStyle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.password),
|
||||
value = state.password,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(CreatePasswordAction.PasswordChanged(it)) }
|
||||
},
|
||||
cardStyle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(R.string.execute),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(CreatePasswordAction.ExecuteClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(BitwardenString.clear),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(CreatePasswordAction.ClearResultClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.result),
|
||||
value = state.resultText,
|
||||
onValueChange = { },
|
||||
cardStyle = null,
|
||||
readOnly = true,
|
||||
singleLine = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpassword
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val MAX_STACK_TRACE_LINES = 5
|
||||
private const val RESULT_SEPARATOR_LENGTH = 40
|
||||
|
||||
/**
|
||||
* ViewModel for Create Password test screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CreatePasswordViewModel @Inject constructor(
|
||||
private val credentialTestManager: CredentialTestManager,
|
||||
private val clock: Clock,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<CreatePasswordState, CreatePasswordEvent, CreatePasswordAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: CreatePasswordState(),
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: CreatePasswordAction) {
|
||||
when (action) {
|
||||
is CreatePasswordAction.UsernameChanged -> handleUsernameChanged(action)
|
||||
is CreatePasswordAction.PasswordChanged -> handlePasswordChanged(action)
|
||||
CreatePasswordAction.ExecuteClick -> handleExecuteClick()
|
||||
CreatePasswordAction.ClearResultClick -> handleClearResultClick()
|
||||
CreatePasswordAction.BackClick -> handleBackClick()
|
||||
is CreatePasswordAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(CreatePasswordEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleUsernameChanged(action: CreatePasswordAction.UsernameChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(username = action.username)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePasswordChanged(action: CreatePasswordAction.PasswordChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(password = action.password)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExecuteClick() {
|
||||
val currentState = stateFlow.value
|
||||
val username = currentState.username
|
||||
val password = currentState.password
|
||||
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
|
||||
"Username and password are required\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
resultText = it.resultText + errorMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startMessage = "\n${timestamp()} ⏳ Creating password credential...\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
resultText = it.resultText + startMessage,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = credentialTestManager.createPassword(
|
||||
username = username,
|
||||
password = password,
|
||||
origin = null,
|
||||
)
|
||||
sendAction(CreatePasswordAction.Internal.CredentialResultReceived(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearResultClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(resultText = "Result cleared.\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: CreatePasswordAction.Internal) {
|
||||
when (action) {
|
||||
is CreatePasswordAction.Internal.CredentialResultReceived -> {
|
||||
handleCredentialResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCredentialResultReceived(
|
||||
action: CreatePasswordAction.Internal.CredentialResultReceived,
|
||||
) {
|
||||
val resultMessage = when (val result = action.result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
buildString {
|
||||
append("${timestamp()} ✅ SUCCESS: Password created successfully\n")
|
||||
if (result.data != null) {
|
||||
append("\n📋 Response Data:\n${result.data}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is CredentialTestResult.Error -> {
|
||||
buildString {
|
||||
val errorMessage = result.exception?.message ?: "Unknown error"
|
||||
append("${timestamp()} ❌ ERROR: Failed to create password: $errorMessage\n")
|
||||
if (result.exception != null) {
|
||||
append("\n🔍 Exception:\n${result.exception}\n")
|
||||
append("\nStack trace:\n")
|
||||
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
|
||||
append(" at $it\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CredentialTestResult.Cancelled -> {
|
||||
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
|
||||
}
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
resultText = it.resultText + resultMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timestamp(): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
return clock.instant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(formatter)
|
||||
.let { "[$it]" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Create Password screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CreatePasswordState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val resultText: String = "Ready to create password credential.\n\n" +
|
||||
"Enter username and password, then click Execute.",
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Events for Create Password screen.
|
||||
*/
|
||||
sealed class CreatePasswordEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : CreatePasswordEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Create Password screen.
|
||||
*/
|
||||
sealed class CreatePasswordAction {
|
||||
/**
|
||||
* Username input value changed.
|
||||
*/
|
||||
data class UsernameChanged(val username: String) : CreatePasswordAction()
|
||||
|
||||
/**
|
||||
* Password input value changed.
|
||||
*/
|
||||
data class PasswordChanged(val password: String) : CreatePasswordAction()
|
||||
|
||||
/**
|
||||
* User clicked execute button to create password credential.
|
||||
*/
|
||||
data object ExecuteClick : CreatePasswordAction()
|
||||
|
||||
/**
|
||||
* User clicked clear button to reset results.
|
||||
*/
|
||||
data object ClearResultClick : CreatePasswordAction()
|
||||
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : CreatePasswordAction()
|
||||
|
||||
/**
|
||||
* Internal actions for Create Password screen.
|
||||
*/
|
||||
sealed class Internal : CreatePasswordAction() {
|
||||
/**
|
||||
* Credential operation result received.
|
||||
*/
|
||||
data class CredentialResultReceived(
|
||||
val result: CredentialTestResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.navigation
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.testharness.ui.platform.feature.createpasskey.createPasskeyDestination
|
||||
import com.bitwarden.testharness.ui.platform.feature.createpasskey.navigateToCreatePasskey
|
||||
import com.bitwarden.testharness.ui.platform.feature.createpassword.createPasswordDestination
|
||||
import com.bitwarden.testharness.ui.platform.feature.createpassword.navigateToCreatePassword
|
||||
import com.bitwarden.testharness.ui.platform.feature.getpasskey.getPasskeyDestination
|
||||
import com.bitwarden.testharness.ui.platform.feature.getpasskey.navigateToGetPasskey
|
||||
import com.bitwarden.testharness.ui.platform.feature.getpassword.getPasswordDestination
|
||||
import com.bitwarden.testharness.ui.platform.feature.getpassword.navigateToGetPassword
|
||||
import com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey.getPasswordOrPasskeyDestination
|
||||
import com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey.navigateToGetPasswordOrPasskey
|
||||
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Credential Manager graph route - serves as the parent for all Credential Manager API test flows.
|
||||
*/
|
||||
@Serializable
|
||||
data object CredentialManagerGraphRoute
|
||||
|
||||
/**
|
||||
* Credential Manager test category list screen - the start destination of the graph.
|
||||
*/
|
||||
@Serializable
|
||||
data object CredentialManagerListRoute
|
||||
|
||||
/**
|
||||
* Add Credential Manager nav graph to the root nav graph.
|
||||
*
|
||||
* This graph contains the list screen and all nested credential manager API test screens.
|
||||
*/
|
||||
fun NavGraphBuilder.credentialManagerGraph(
|
||||
onNavigateBack: () -> Unit,
|
||||
navController: NavController,
|
||||
) {
|
||||
navigation<CredentialManagerGraphRoute>(
|
||||
startDestination = CredentialManagerListRoute,
|
||||
) {
|
||||
credentialManagerListDestination(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToGetPassword = {
|
||||
navController.navigateToGetPassword()
|
||||
},
|
||||
onNavigateToCreatePassword = {
|
||||
navController.navigateToCreatePassword()
|
||||
},
|
||||
onNavigateToGetPasskey = {
|
||||
navController.navigateToGetPasskey()
|
||||
},
|
||||
onNavigateToCreatePasskey = {
|
||||
navController.navigateToCreatePasskey()
|
||||
},
|
||||
onNavigateToGetPasswordOrPasskey = {
|
||||
navController.navigateToGetPasswordOrPasskey()
|
||||
},
|
||||
)
|
||||
|
||||
getPasswordDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
||||
createPasswordDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
||||
getPasskeyDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
||||
createPasskeyDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
||||
getPasswordOrPasskeyDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun NavGraphBuilder.credentialManagerListDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToGetPassword: () -> Unit,
|
||||
onNavigateToCreatePassword: () -> Unit,
|
||||
onNavigateToGetPasskey: () -> Unit,
|
||||
onNavigateToCreatePasskey: () -> Unit,
|
||||
onNavigateToGetPasswordOrPasskey: () -> Unit,
|
||||
) {
|
||||
composableWithRootPushTransitions<CredentialManagerListRoute> {
|
||||
CredentialManagerListScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToGetPassword = { onNavigateToGetPassword() },
|
||||
onNavigateToCreatePassword = { onNavigateToCreatePassword() },
|
||||
onNavigateToGetPasskey = { onNavigateToGetPasskey() },
|
||||
onNavigateToCreatePasskey = { onNavigateToCreatePasskey() },
|
||||
onNavigateToGetPasswordOrPasskey = { onNavigateToGetPasswordOrPasskey() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Credential Manager flow.
|
||||
*/
|
||||
fun NavController.navigateToCredentialManagerGraph(navOptions: NavOptions? = null) {
|
||||
navigate(route = CredentialManagerGraphRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.row.BitwardenPushRow
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* Screen displaying available Credential Manager test flows.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CredentialManagerListScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToGetPassword: () -> Unit,
|
||||
onNavigateToCreatePassword: () -> Unit,
|
||||
onNavigateToGetPasskey: () -> Unit,
|
||||
onNavigateToCreatePasskey: () -> Unit,
|
||||
onNavigateToGetPasswordOrPasskey: () -> Unit,
|
||||
viewModel: CredentialManagerListViewModel = hiltViewModel(),
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
CredentialManagerListEvent.NavigateToGetPassword -> {
|
||||
onNavigateToGetPassword()
|
||||
}
|
||||
|
||||
CredentialManagerListEvent.NavigateToCreatePassword -> {
|
||||
onNavigateToCreatePassword()
|
||||
}
|
||||
|
||||
CredentialManagerListEvent.NavigateToGetPasskey -> {
|
||||
onNavigateToGetPasskey()
|
||||
}
|
||||
|
||||
CredentialManagerListEvent.NavigateToCreatePasskey -> {
|
||||
onNavigateToCreatePasskey()
|
||||
}
|
||||
|
||||
CredentialManagerListEvent.NavigateToGetPasswordOrPasskey -> {
|
||||
onNavigateToGetPasswordOrPasskey()
|
||||
}
|
||||
|
||||
CredentialManagerListEvent.NavigateBack -> {
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.credential_manager),
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(CredentialManagerListAction.BackClick) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.credential_manager_flows),
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.get_password),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasswordClick)
|
||||
}
|
||||
},
|
||||
cardStyle = CardStyle.Top(),
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.create_password),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(CredentialManagerListAction.CreatePasswordClick)
|
||||
}
|
||||
},
|
||||
cardStyle = CardStyle.Middle(),
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.get_passkey),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasskeyClick)
|
||||
}
|
||||
},
|
||||
cardStyle = CardStyle.Middle(),
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.create_passkey),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(CredentialManagerListAction.CreatePasskeyClick)
|
||||
}
|
||||
},
|
||||
cardStyle = CardStyle.Middle(),
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.get_password_or_passkey),
|
||||
onClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
CredentialManagerListAction.GetPasswordOrPasskeyClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
cardStyle = CardStyle.Bottom,
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
|
||||
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for Credential Manager List screen.
|
||||
* Manages navigation events to different credential manager test flows.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CredentialManagerListViewModel @Inject constructor() :
|
||||
BaseViewModel<Unit, CredentialManagerListEvent, CredentialManagerListAction>(
|
||||
initialState = Unit,
|
||||
) {
|
||||
|
||||
override fun handleAction(action: CredentialManagerListAction) {
|
||||
when (action) {
|
||||
CredentialManagerListAction.GetPasswordClick -> handleGetPasswordClick()
|
||||
CredentialManagerListAction.CreatePasswordClick -> handleCreatePasswordClick()
|
||||
CredentialManagerListAction.GetPasskeyClick -> handleGetPasskeyClick()
|
||||
CredentialManagerListAction.CreatePasskeyClick -> handleCreatePasskeyClick()
|
||||
CredentialManagerListAction.GetPasswordOrPasskeyClick -> {
|
||||
handleGetPasswordOrPasskeyClick()
|
||||
}
|
||||
CredentialManagerListAction.BackClick -> handleBackClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetPasswordClick() {
|
||||
sendEvent(CredentialManagerListEvent.NavigateToGetPassword)
|
||||
}
|
||||
|
||||
private fun handleCreatePasswordClick() {
|
||||
sendEvent(CredentialManagerListEvent.NavigateToCreatePassword)
|
||||
}
|
||||
|
||||
private fun handleGetPasskeyClick() {
|
||||
sendEvent(CredentialManagerListEvent.NavigateToGetPasskey)
|
||||
}
|
||||
|
||||
private fun handleCreatePasskeyClick() {
|
||||
sendEvent(CredentialManagerListEvent.NavigateToCreatePasskey)
|
||||
}
|
||||
|
||||
private fun handleGetPasswordOrPasskeyClick() {
|
||||
sendEvent(CredentialManagerListEvent.NavigateToGetPasswordOrPasskey)
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(CredentialManagerListEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events for Credential Manager List screen.
|
||||
*/
|
||||
sealed class CredentialManagerListEvent {
|
||||
/**
|
||||
* Navigate to Get Password test screen.
|
||||
*/
|
||||
data object NavigateToGetPassword : CredentialManagerListEvent()
|
||||
|
||||
/**
|
||||
* Navigate to Create Password test screen.
|
||||
*/
|
||||
data object NavigateToCreatePassword : CredentialManagerListEvent()
|
||||
|
||||
/**
|
||||
* Navigate to Get Passkey test screen.
|
||||
*/
|
||||
data object NavigateToGetPasskey : CredentialManagerListEvent()
|
||||
|
||||
/**
|
||||
* Navigate to Create Passkey test screen.
|
||||
*/
|
||||
data object NavigateToCreatePasskey : CredentialManagerListEvent()
|
||||
|
||||
/**
|
||||
* Navigate to Get Password or Passkey test screen.
|
||||
*/
|
||||
data object NavigateToGetPasswordOrPasskey : CredentialManagerListEvent()
|
||||
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : CredentialManagerListEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Credential Manager List screen.
|
||||
*/
|
||||
sealed class CredentialManagerListAction {
|
||||
/**
|
||||
* User clicked Get Password button.
|
||||
*/
|
||||
data object GetPasswordClick : CredentialManagerListAction()
|
||||
|
||||
/**
|
||||
* User clicked Create Password button.
|
||||
*/
|
||||
data object CreatePasswordClick : CredentialManagerListAction()
|
||||
|
||||
/**
|
||||
* User clicked Get Passkey button.
|
||||
*/
|
||||
data object GetPasskeyClick : CredentialManagerListAction()
|
||||
|
||||
/**
|
||||
* User clicked Create Passkey button.
|
||||
*/
|
||||
data object CreatePasskeyClick : CredentialManagerListAction()
|
||||
|
||||
/**
|
||||
* User clicked Get Password or Passkey button.
|
||||
*/
|
||||
data object GetPasswordOrPasskeyClick : CredentialManagerListAction()
|
||||
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : CredentialManagerListAction()
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasskey
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Get passkey test screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object GetPasskeyRoute
|
||||
|
||||
/**
|
||||
* Add Get Passkey destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.getPasskeyDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<GetPasskeyRoute> {
|
||||
GetPasskeyScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Get Passkey test screen.
|
||||
*/
|
||||
fun NavController.navigateToGetPasskey(navOptions: NavOptions? = null) {
|
||||
navigate(route = GetPasskeyRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasskey
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* Get Passkey test screen.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GetPasskeyScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: GetPasskeyViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
GetPasskeyEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.get_passkey_title),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasskeyAction.BackClick) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.relying_party_id),
|
||||
value = state.rpId,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasskeyAction.RpIdChanged(it)) }
|
||||
},
|
||||
placeholder = stringResource(R.string.rp_id_hint),
|
||||
cardStyle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.origin_optional),
|
||||
value = state.origin,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasskeyAction.OriginChanged(it)) }
|
||||
},
|
||||
placeholder = stringResource(R.string.origin_hint),
|
||||
cardStyle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(R.string.execute),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasskeyAction.ExecuteClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(R.string.clear),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasskeyAction.ClearResultClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.result),
|
||||
value = state.resultText,
|
||||
onValueChange = { },
|
||||
cardStyle = null,
|
||||
readOnly = true,
|
||||
singleLine = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,232 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasskey
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val MAX_STACK_TRACE_LINES = 5
|
||||
private const val RESULT_SEPARATOR_LENGTH = 40
|
||||
|
||||
/**
|
||||
* ViewModel for Get Passkey test screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class GetPasskeyViewModel @Inject constructor(
|
||||
private val credentialTestManager: CredentialTestManager,
|
||||
private val clock: Clock,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<GetPasskeyState, GetPasskeyEvent, GetPasskeyAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: GetPasskeyState(),
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: GetPasskeyAction) {
|
||||
when (action) {
|
||||
is GetPasskeyAction.RpIdChanged -> handleRpIdChanged(action)
|
||||
is GetPasskeyAction.OriginChanged -> handleOriginChanged(action)
|
||||
GetPasskeyAction.ExecuteClick -> handleExecuteClick()
|
||||
GetPasskeyAction.ClearResultClick -> handleClearResultClick()
|
||||
GetPasskeyAction.BackClick -> handleBackClick()
|
||||
is GetPasskeyAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(GetPasskeyEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleRpIdChanged(action: GetPasskeyAction.RpIdChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(rpId = action.rpId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOriginChanged(action: GetPasskeyAction.OriginChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(origin = action.origin)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExecuteClick() {
|
||||
val currentState = stateFlow.value
|
||||
val rpId = currentState.rpId
|
||||
val origin = currentState.origin.takeIf { it.isNotBlank() }
|
||||
|
||||
if (rpId.isBlank()) {
|
||||
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
|
||||
"Relying Party ID is required\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
resultText = it.resultText + errorMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startMessage = "\n${timestamp()} ⏳ Starting passkey authentication...\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
resultText = it.resultText + startMessage,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = credentialTestManager.getPasskey(
|
||||
rpId = rpId,
|
||||
origin = origin,
|
||||
)
|
||||
sendAction(GetPasskeyAction.Internal.CredentialResultReceived(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearResultClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(resultText = "Result cleared.\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: GetPasskeyAction.Internal) {
|
||||
when (action) {
|
||||
is GetPasskeyAction.Internal.CredentialResultReceived -> {
|
||||
handleCredentialResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCredentialResultReceived(
|
||||
action: GetPasskeyAction.Internal.CredentialResultReceived,
|
||||
) {
|
||||
val resultMessage = when (val result = action.result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
buildString {
|
||||
append("${timestamp()} ✅ SUCCESS: Passkey authenticated successfully\n")
|
||||
if (result.data != null) {
|
||||
append("\n📋 Response Data:\n${result.data}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is CredentialTestResult.Error -> {
|
||||
buildString {
|
||||
val errorMessage = result.exception?.message ?: "Unknown error"
|
||||
append(
|
||||
"${timestamp()} ❌ ERROR: Failed to authenticate passkey: " +
|
||||
"$errorMessage\n",
|
||||
)
|
||||
if (result.exception != null) {
|
||||
append("\n🔍 Exception:\n${result.exception}\n")
|
||||
append("\nStack trace:\n")
|
||||
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
|
||||
append(" at $it\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CredentialTestResult.Cancelled -> {
|
||||
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
|
||||
}
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
resultText = it.resultText + resultMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timestamp(): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
return clock.instant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(formatter)
|
||||
.let { "[$it]" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Get Passkey screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class GetPasskeyState(
|
||||
val rpId: String = "",
|
||||
val origin: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val resultText: String = "Ready to authenticate passkey.\n\n" +
|
||||
"Enter Relying Party ID, then click Execute.",
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Events for Get Passkey screen.
|
||||
*/
|
||||
sealed class GetPasskeyEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : GetPasskeyEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Get Passkey screen.
|
||||
*/
|
||||
sealed class GetPasskeyAction {
|
||||
/**
|
||||
* Relying Party ID input value changed.
|
||||
*/
|
||||
data class RpIdChanged(val rpId: String) : GetPasskeyAction()
|
||||
|
||||
/**
|
||||
* Origin input value changed.
|
||||
*/
|
||||
data class OriginChanged(val origin: String) : GetPasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked execute button to authenticate passkey.
|
||||
*/
|
||||
data object ExecuteClick : GetPasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked clear button to reset results.
|
||||
*/
|
||||
data object ClearResultClick : GetPasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : GetPasskeyAction()
|
||||
|
||||
/**
|
||||
* Internal actions for Get Passkey screen.
|
||||
*/
|
||||
sealed class Internal : GetPasskeyAction() {
|
||||
/**
|
||||
* Credential operation result received.
|
||||
*/
|
||||
data class CredentialResultReceived(
|
||||
val result: CredentialTestResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpassword
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Get password test screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object GetPasswordRoute
|
||||
|
||||
/**
|
||||
* Add Get Password destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.getPasswordDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<GetPasswordRoute> {
|
||||
GetPasswordScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Get Password test screen.
|
||||
*/
|
||||
fun NavController.navigateToGetPassword(navOptions: NavOptions? = null) {
|
||||
navigate(route = GetPasswordRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpassword
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
/**
|
||||
* Get Password test screen.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GetPasswordScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: GetPasswordViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
GetPasswordEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.get_password_title),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordAction.BackClick) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.no_inputs_required),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(R.string.execute),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordAction.ExecuteClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(R.string.clear),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordAction.ClearResultClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.result),
|
||||
value = state.resultText,
|
||||
onValueChange = { },
|
||||
cardStyle = null,
|
||||
readOnly = true,
|
||||
singleLine = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpassword
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val MAX_STACK_TRACE_LINES = 5
|
||||
private const val RESULT_SEPARATOR_LENGTH = 40
|
||||
|
||||
/**
|
||||
* ViewModel for Get Password test screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class GetPasswordViewModel @Inject constructor(
|
||||
private val credentialTestManager: CredentialTestManager,
|
||||
private val clock: Clock,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<GetPasswordState, GetPasswordEvent, GetPasswordAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: GetPasswordState(),
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: GetPasswordAction) {
|
||||
when (action) {
|
||||
GetPasswordAction.ExecuteClick -> handleExecuteClick()
|
||||
GetPasswordAction.ClearResultClick -> handleClearResultClick()
|
||||
GetPasswordAction.BackClick -> handleBackClick()
|
||||
is GetPasswordAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(GetPasswordEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleExecuteClick() {
|
||||
val startMessage = "\n${timestamp()} ⏳ Starting password retrieval...\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
resultText = it.resultText + startMessage,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = credentialTestManager.getPassword()
|
||||
sendAction(GetPasswordAction.Internal.CredentialResultReceived(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearResultClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(resultText = "Result cleared.\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: GetPasswordAction.Internal) {
|
||||
when (action) {
|
||||
is GetPasswordAction.Internal.CredentialResultReceived -> {
|
||||
handleCredentialResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCredentialResultReceived(
|
||||
action: GetPasswordAction.Internal.CredentialResultReceived,
|
||||
) {
|
||||
val resultMessage = when (val result = action.result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
buildString {
|
||||
append("${timestamp()} ✅ SUCCESS: Password retrieved successfully\n")
|
||||
if (result.data != null) {
|
||||
append("\n📋 Response Data:\n${result.data}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is CredentialTestResult.Error -> {
|
||||
buildString {
|
||||
val errorMessage = result.exception?.message ?: "Unknown error"
|
||||
append("${timestamp()} ❌ ERROR: Failed to get password: $errorMessage\n")
|
||||
if (result.exception != null) {
|
||||
append("\n🔍 Exception:\n${result.exception}\n")
|
||||
append("\nStack trace:\n")
|
||||
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
|
||||
append(" at $it\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CredentialTestResult.Cancelled -> {
|
||||
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
|
||||
}
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
resultText = it.resultText + resultMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timestamp(): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
return clock.instant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(formatter)
|
||||
.let { "[$it]" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Get Password screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class GetPasswordState(
|
||||
val isLoading: Boolean = false,
|
||||
val resultText: String = "Ready to retrieve password.\n\n" +
|
||||
"Click Execute to open the password picker.",
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Events for Get Password screen.
|
||||
*/
|
||||
sealed class GetPasswordEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : GetPasswordEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Get Password screen.
|
||||
*/
|
||||
sealed class GetPasswordAction {
|
||||
/**
|
||||
* User clicked execute button to retrieve password.
|
||||
*/
|
||||
data object ExecuteClick : GetPasswordAction()
|
||||
|
||||
/**
|
||||
* User clicked clear button to reset results.
|
||||
*/
|
||||
data object ClearResultClick : GetPasswordAction()
|
||||
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : GetPasswordAction()
|
||||
|
||||
/**
|
||||
* Internal actions for Get Password screen.
|
||||
*/
|
||||
sealed class Internal : GetPasswordAction() {
|
||||
/**
|
||||
* Credential operation result received.
|
||||
*/
|
||||
data class CredentialResultReceived(
|
||||
val result: CredentialTestResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Get Password or Passkey test screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object GetPasswordOrPasskeyRoute
|
||||
|
||||
/**
|
||||
* Add Get Password or Passkey destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.getPasswordOrPasskeyDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<GetPasswordOrPasskeyRoute> {
|
||||
GetPasswordOrPasskeyScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Get Password or Passkey test screen.
|
||||
*/
|
||||
fun NavController.navigateToGetPasswordOrPasskey(navOptions: NavOptions? = null) {
|
||||
navigate(route = GetPasswordOrPasskeyRoute, navOptions = navOptions)
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* Get Password or Passkey test screen.
|
||||
*
|
||||
* This screen tests the combined credential retrieval use case where both GetPasswordOption
|
||||
* and GetPublicKeyCredentialOption are included in a single GetCredentialRequest. The system
|
||||
* credential picker will show both passwords and passkeys, allowing the user to select either
|
||||
* credential type.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GetPasswordOrPasskeyScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: GetPasswordOrPasskeyViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
GetPasswordOrPasskeyEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.get_password_or_passkey_title),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = NavigationIcon(
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.BackClick) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.verticalScroll(state = rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.relying_party_id),
|
||||
value = state.rpId,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged(it)) }
|
||||
},
|
||||
placeholder = stringResource(R.string.rp_id_hint),
|
||||
cardStyle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.origin_optional),
|
||||
value = state.origin,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged(it)) }
|
||||
},
|
||||
placeholder = stringResource(R.string.origin_hint),
|
||||
cardStyle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(R.string.execute),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(R.string.clear),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.ClearResultClick) }
|
||||
},
|
||||
isEnabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(R.string.result),
|
||||
value = state.resultText,
|
||||
onValueChange = { },
|
||||
cardStyle = null,
|
||||
readOnly = true,
|
||||
singleLine = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val MAX_STACK_TRACE_LINES = 5
|
||||
private const val RESULT_SEPARATOR_LENGTH = 40
|
||||
|
||||
/**
|
||||
* ViewModel for Get Password or Passkey test screen.
|
||||
*
|
||||
* This ViewModel handles the combined credential retrieval test where both password and
|
||||
* passkey options are included in a single GetCredentialRequest.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class GetPasswordOrPasskeyViewModel @Inject constructor(
|
||||
private val credentialTestManager: CredentialTestManager,
|
||||
private val clock: Clock,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<GetPasswordOrPasskeyState, GetPasswordOrPasskeyEvent, GetPasswordOrPasskeyAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: GetPasswordOrPasskeyState(),
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: GetPasswordOrPasskeyAction) {
|
||||
when (action) {
|
||||
is GetPasswordOrPasskeyAction.RpIdChanged -> handleRpIdChanged(action)
|
||||
is GetPasswordOrPasskeyAction.OriginChanged -> handleOriginChanged(action)
|
||||
GetPasswordOrPasskeyAction.ExecuteClick -> handleExecuteClick()
|
||||
GetPasswordOrPasskeyAction.ClearResultClick -> handleClearResultClick()
|
||||
GetPasswordOrPasskeyAction.BackClick -> handleBackClick()
|
||||
is GetPasswordOrPasskeyAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(GetPasswordOrPasskeyEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleRpIdChanged(action: GetPasswordOrPasskeyAction.RpIdChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(rpId = action.rpId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOriginChanged(action: GetPasswordOrPasskeyAction.OriginChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(origin = action.origin)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExecuteClick() {
|
||||
val currentState = stateFlow.value
|
||||
val rpId = currentState.rpId
|
||||
val origin = currentState.origin.takeIf { it.isNotBlank() }
|
||||
|
||||
if (rpId.isBlank()) {
|
||||
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
|
||||
"Relying Party ID is required\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
resultText = it.resultText + errorMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startMessage = "\n${timestamp()} ⏳ Starting credential retrieval...\n"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
resultText = it.resultText + startMessage,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = credentialTestManager.getPasswordOrPasskey(
|
||||
rpId = rpId,
|
||||
origin = origin,
|
||||
)
|
||||
sendAction(GetPasswordOrPasskeyAction.Internal.CredentialResultReceived(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearResultClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(resultText = "Result cleared.\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: GetPasswordOrPasskeyAction.Internal) {
|
||||
when (action) {
|
||||
is GetPasswordOrPasskeyAction.Internal.CredentialResultReceived -> {
|
||||
handleCredentialResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCredentialResultReceived(
|
||||
action: GetPasswordOrPasskeyAction.Internal.CredentialResultReceived,
|
||||
) {
|
||||
val resultMessage = when (val result = action.result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
buildString {
|
||||
// Determine credential type from data to construct appropriate message
|
||||
val successMessage = when {
|
||||
result.data?.startsWith("Type: PASSWORD") == true ->
|
||||
"Password retrieved successfully"
|
||||
|
||||
result.data?.startsWith("Type: PASSKEY") == true ->
|
||||
"Passkey authenticated successfully"
|
||||
|
||||
else -> "Credential retrieved successfully"
|
||||
}
|
||||
append("${timestamp()} ✅ SUCCESS: $successMessage\n")
|
||||
if (result.data != null) {
|
||||
append("\n📋 Response Data:\n${result.data}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is CredentialTestResult.Error -> {
|
||||
buildString {
|
||||
val errorMessage = result.exception?.message ?: "Unknown error"
|
||||
append("${timestamp()} ❌ ERROR: Failed to get credential: $errorMessage\n")
|
||||
if (result.exception != null) {
|
||||
append("\n🔍 Exception:\n${result.exception}\n")
|
||||
append("\nStack trace:\n")
|
||||
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
|
||||
append(" at $it\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CredentialTestResult.Cancelled -> {
|
||||
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
|
||||
}
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
resultText = it.resultText + resultMessage + "\n" +
|
||||
"─".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun timestamp(): String {
|
||||
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
return clock.instant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(formatter)
|
||||
.let { "[$it]" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for Get Password or Passkey screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class GetPasswordOrPasskeyState(
|
||||
val rpId: String = "",
|
||||
val origin: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val resultText: String = "Ready to retrieve password or passkey.\n\n" +
|
||||
"Enter Relying Party ID, then click Execute.\n" +
|
||||
"System picker will show both passwords and passkeys.",
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Events for Get Password or Passkey screen.
|
||||
*/
|
||||
sealed class GetPasswordOrPasskeyEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : GetPasswordOrPasskeyEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for Get Password or Passkey screen.
|
||||
*/
|
||||
sealed class GetPasswordOrPasskeyAction {
|
||||
/**
|
||||
* Relying Party ID input value changed.
|
||||
*/
|
||||
data class RpIdChanged(val rpId: String) : GetPasswordOrPasskeyAction()
|
||||
|
||||
/**
|
||||
* Origin input value changed.
|
||||
*/
|
||||
data class OriginChanged(val origin: String) : GetPasswordOrPasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked execute button to retrieve password or passkey.
|
||||
*/
|
||||
data object ExecuteClick : GetPasswordOrPasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked clear button to reset results.
|
||||
*/
|
||||
data object ClearResultClick : GetPasswordOrPasskeyAction()
|
||||
|
||||
/**
|
||||
* User clicked back button.
|
||||
*/
|
||||
data object BackClick : GetPasswordOrPasskeyAction()
|
||||
|
||||
/**
|
||||
* Internal actions for Get Password or Passkey screen.
|
||||
*/
|
||||
sealed class Internal : GetPasswordOrPasskeyAction() {
|
||||
/**
|
||||
* Credential operation result received.
|
||||
*/
|
||||
data class CredentialResultReceived(
|
||||
val result: CredentialTestResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.landing
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Landing screen - main entry point showing test category options.
|
||||
*/
|
||||
@Serializable
|
||||
data object LandingRoute
|
||||
|
||||
/**
|
||||
* Add Landing destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.landingDestination(
|
||||
onNavigateToAutofill: () -> Unit,
|
||||
onNavigateToCredentialManager: () -> Unit,
|
||||
) {
|
||||
composableWithRootPushTransitions<LandingRoute> {
|
||||
LandingScreen(
|
||||
onNavigateToAutofill = onNavigateToAutofill,
|
||||
onNavigateToCredentialManager = onNavigateToCredentialManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.landing
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.bitwarden.testharness.R
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.row.BitwardenPushRow
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
|
||||
/**
|
||||
* Landing screen displaying test category options.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LandingScreen(
|
||||
onNavigateToAutofill: () -> Unit,
|
||||
onNavigateToCredentialManager: () -> Unit,
|
||||
viewModel: LandingViewModel = hiltViewModel(),
|
||||
) {
|
||||
|
||||
EventsEffect(viewModel) { event ->
|
||||
when (event) {
|
||||
LandingEvent.NavigateToAutofill -> onNavigateToAutofill()
|
||||
LandingEvent.NavigateToCredentialManager -> onNavigateToCredentialManager()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
LandingScreenContent(
|
||||
onAutofillClick = {
|
||||
viewModel.trySendAction(LandingAction.OnAutofillClick)
|
||||
},
|
||||
onCredentialManagerClick = {
|
||||
viewModel.trySendAction(LandingAction.OnCredentialManagerClick)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LandingScreenContent(
|
||||
onAutofillClick: () -> Unit,
|
||||
onCredentialManagerClick: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.app_name),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.test_categories),
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.autofill),
|
||||
onClick = onAutofillClick,
|
||||
cardStyle = CardStyle.Top(),
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
BitwardenPushRow(
|
||||
text = stringResource(id = R.string.credential_manager),
|
||||
onClick = onCredentialManagerClick,
|
||||
cardStyle = CardStyle.Bottom,
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LandingScreenPreview() {
|
||||
LandingScreenContent(
|
||||
onAutofillClick = {},
|
||||
onCredentialManagerClick = {},
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.landing
|
||||
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel for the Landing screen.
|
||||
*
|
||||
* Manages navigation events for the test category selection screen following UDF patterns.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LandingViewModel @Inject constructor() : BaseViewModel<Unit, LandingEvent, LandingAction>(
|
||||
initialState = Unit,
|
||||
) {
|
||||
override fun handleAction(action: LandingAction) {
|
||||
when (action) {
|
||||
LandingAction.OnAutofillClick -> {
|
||||
handleAutofillClicked()
|
||||
}
|
||||
|
||||
LandingAction.OnCredentialManagerClick -> {
|
||||
handleCredentialManagerClicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAutofillClicked() {
|
||||
sendEvent(LandingEvent.NavigateToAutofill)
|
||||
}
|
||||
|
||||
private fun handleCredentialManagerClicked() {
|
||||
sendEvent(LandingEvent.NavigateToCredentialManager)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events emitted by the Landing screen.
|
||||
*/
|
||||
sealed class LandingEvent {
|
||||
/**
|
||||
* Navigates to the Autofill test flow.
|
||||
*/
|
||||
data object NavigateToAutofill : LandingEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the Credential Manager test flow.
|
||||
*/
|
||||
data object NavigateToCredentialManager : LandingEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the Landing screen.
|
||||
*/
|
||||
sealed class LandingAction {
|
||||
/**
|
||||
* Indicates the user clicked the Autofill test option.
|
||||
*/
|
||||
data object OnAutofillClick : LandingAction()
|
||||
|
||||
/**
|
||||
* Indicates the user clicked the Credential Manager test option.
|
||||
*/
|
||||
data object OnCredentialManagerClick : LandingAction()
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.bitwarden.testharness.ui.platform.feature.rootnav
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the root navigation screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object RootNavigationRoute
|
||||
|
||||
/**
|
||||
* Add the root navigation screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.rootNavDestination(
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
composable<RootNavigationRoute> {
|
||||
RootNavScreen(onSplashScreenRemoved = onSplashScreenRemoved)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.rootnav
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.bitwarden.testharness.ui.platform.feature.autofill.autofillGraph
|
||||
import com.bitwarden.testharness.ui.platform.feature.autofill.navigateToAutofillGraph
|
||||
import com.bitwarden.testharness.ui.platform.feature.credentialmanager.credentialManagerGraph
|
||||
import com.bitwarden.testharness.ui.platform.feature.credentialmanager.navigateToCredentialManagerGraph
|
||||
import com.bitwarden.testharness.ui.platform.feature.landing.LandingRoute
|
||||
import com.bitwarden.testharness.ui.platform.feature.landing.landingDestination
|
||||
|
||||
/**
|
||||
* Controls the root level [NavHost] for the test harness app.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun RootNavScreen(
|
||||
viewModel: RootNavViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberNavController(),
|
||||
onSplashScreenRemoved: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state != RootNavState.Splash) {
|
||||
onSplashScreenRemoved()
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = LandingRoute,
|
||||
) {
|
||||
landingDestination(
|
||||
onNavigateToAutofill = {
|
||||
navController.navigateToAutofillGraph()
|
||||
},
|
||||
onNavigateToCredentialManager = {
|
||||
navController.navigateToCredentialManagerGraph()
|
||||
},
|
||||
)
|
||||
|
||||
autofillGraph(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
||||
credentialManagerGraph(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.rootnav
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Manages root level navigation state for the test harness application.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RootNavViewModel @Inject constructor() : BaseViewModel<RootNavState, Unit, RootNavAction>(
|
||||
initialState = RootNavState.Splash,
|
||||
) {
|
||||
|
||||
init {
|
||||
mutableStateFlow.update { RootNavState.Landing }
|
||||
}
|
||||
|
||||
override fun handleAction(action: RootNavAction) {
|
||||
// Handle actions if necessary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models root level navigation destinations for the test harness.
|
||||
*/
|
||||
sealed class RootNavState : Parcelable {
|
||||
|
||||
/**
|
||||
* App should show splash nav graph.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Splash : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show the main landing screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Landing : RootNavState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models root navigation actions.
|
||||
*/
|
||||
sealed class RootNavAction
|
||||
11
testharness/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
11
testharness/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Simple placeholder: letter "T" for Test Harness -->
|
||||
<path
|
||||
android:fillColor="#FF6200EE"
|
||||
android:pathData="M32,32h44v8h-18v36h-8v-36h-18z"/>
|
||||
</vector>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
13
testharness/src/main/res/values/asset_statements.xml
Normal file
13
testharness/src/main/res/values/asset_statements.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Test harness acts as a credential consumer client, not a provider.
|
||||
Digital Asset Links references Bitwarden's assetlinks to satisfy Credential Manager lint requirement.
|
||||
No actual asset verification is performed as testharness doesn't serve as a credential provider.
|
||||
-->
|
||||
<string name="asset_statements" translatable="false">
|
||||
[{
|
||||
\"include\": \"https://bitwarden.com/.well-known/assetlinks.json\"
|
||||
}]
|
||||
</string>
|
||||
</resources>
|
||||
35
testharness/src/main/res/values/strings.xml
Normal file
35
testharness/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Credential Manager Test Harness</string>
|
||||
<string name="test_categories">Test Categories</string>
|
||||
<string name="autofill">Autofill</string>
|
||||
<string name="credential_manager">Credential Manager</string>
|
||||
<string name="autofill_testing">Autofill Testing</string>
|
||||
<string name="autofill_coming_soon">Autofill testing functionality coming soon</string>
|
||||
<string name="credential_manager_flows">Credential Manager Flows</string>
|
||||
<string name="create_password">Create Password</string>
|
||||
<string name="get_password">Get Password</string>
|
||||
<string name="create_passkey">Create Passkey</string>
|
||||
<string name="get_passkey">Get Passkey</string>
|
||||
<string name="get_password_or_passkey">Get Password or Passkey</string>
|
||||
<string name="create_password_title">Create Password</string>
|
||||
<string name="get_password_title">Get Password</string>
|
||||
<string name="create_passkey_title">Create Passkey</string>
|
||||
<string name="get_passkey_title">Get Passkey</string>
|
||||
<string name="get_password_or_passkey_title">Get Password or Passkey</string>
|
||||
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="origin_optional">Origin (optional)</string>
|
||||
<string name="origin_hint">e.g., https://example.com</string>
|
||||
<string name="relying_party_id">Relying Party ID</string>
|
||||
<string name="rp_id_hint">e.g., example.com</string>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<string name="execute">Execute</string>
|
||||
<string name="clear">Clear</string>
|
||||
|
||||
<!-- Result area -->
|
||||
<string name="result">Result</string>
|
||||
<string name="no_inputs_required">No inputs required. Click Execute to open credential picker.</string>
|
||||
</resources>
|
||||
@ -0,0 +1,26 @@
|
||||
package com.bitwarden.testharness
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MainViewModelTest {
|
||||
|
||||
private lateinit var viewModel: MainViewModel
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
viewModel = MainViewModel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state has default theme`() = runTest {
|
||||
viewModel.stateFlow.test {
|
||||
val state = awaitItem()
|
||||
assertEquals(AppTheme.DEFAULT, state.theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,703 @@
|
||||
package com.bitwarden.testharness.data.manager
|
||||
|
||||
import android.app.Application
|
||||
import androidx.credentials.CreatePasswordResponse
|
||||
import androidx.credentials.CreatePublicKeyCredentialRequest
|
||||
import androidx.credentials.CreatePublicKeyCredentialResponse
|
||||
import androidx.credentials.Credential
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.GetCredentialResponse
|
||||
import androidx.credentials.PasswordCredential
|
||||
import androidx.credentials.PublicKeyCredential
|
||||
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
||||
import androidx.credentials.exceptions.CreateCredentialException
|
||||
import androidx.credentials.exceptions.GetCredentialCancellationException
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import io.mockk.unmockkConstructor
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.json.JSONObject
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.fail
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CredentialTestManagerTest {
|
||||
|
||||
private lateinit var application: Application
|
||||
private lateinit var credentialManager: CredentialManager
|
||||
private lateinit var manager: CredentialTestManagerImpl
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
application = mockk(relaxed = true)
|
||||
credentialManager = mockk()
|
||||
|
||||
manager = CredentialTestManagerImpl(
|
||||
application = application,
|
||||
credentialManager = credentialManager,
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class CreatePassword {
|
||||
|
||||
@Test
|
||||
fun `success without origin returns Success`() = runTest {
|
||||
val response = CreatePasswordResponse()
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.createPassword("user@example.com", "SecurePass123!", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("user@example.com") == true)
|
||||
assertTrue(result.data?.contains("Origin: null") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success with origin returns Success`() = runTest {
|
||||
val response = CreatePasswordResponse()
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.createPassword(
|
||||
username = "user@example.com",
|
||||
password = "SecurePass123!",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("https://example.com") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation exception returns Cancelled`() = runTest {
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws CreateCredentialCancellationException("User cancelled")
|
||||
|
||||
val result = manager.createPassword("user", "pass", null)
|
||||
|
||||
assertTrue(result is CredentialTestResult.Cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create exception returns Error`() = runTest {
|
||||
val exception = mockk<CreateCredentialException>(relaxed = true) {
|
||||
every { message } returns "Failed to create"
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws exception
|
||||
|
||||
val result = manager.createPassword("user", "pass", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertEquals(exception, result.exception)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected response type returns Error`() = runTest {
|
||||
val unexpectedResponse = mockk<CreatePublicKeyCredentialResponse>()
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns unexpectedResponse
|
||||
|
||||
val result = manager.createPassword("user", "pass", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertTrue(result.exception is IllegalStateException)
|
||||
assertTrue(
|
||||
result.exception?.message?.contains("Unexpected response type") == true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class GetPassword {
|
||||
|
||||
@Test
|
||||
fun `success with PasswordCredential returns Success`() = runTest {
|
||||
val passwordCredential = mockk<PasswordCredential> {
|
||||
every { id } returns "user@example.com"
|
||||
every { password } returns "SecurePass123!"
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { credential } returns passwordCredential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPassword()
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("user@example.com") == true)
|
||||
assertTrue(result.data?.contains("SecurePass123!") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation exception returns Cancelled`() = runTest {
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws GetCredentialCancellationException("User cancelled")
|
||||
|
||||
val result = manager.getPassword()
|
||||
|
||||
assertTrue(result is CredentialTestResult.Cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get exception returns Error`() = runTest {
|
||||
val exception = mockk<GetCredentialException>(relaxed = true) {
|
||||
every { message } returns "Failed to get credential"
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws exception
|
||||
|
||||
val result = manager.getPassword()
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertEquals(exception, result.exception)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected credential type returns Error`() = runTest {
|
||||
val unexpectedCredential = mockk<PublicKeyCredential>()
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { credential } returns unexpectedCredential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPassword()
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertTrue(result.exception is IllegalStateException)
|
||||
assertTrue(
|
||||
result.exception?.message?.contains("Unexpected credential type") == true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class CreatePasskey {
|
||||
|
||||
@BeforeEach
|
||||
fun setupConstructorMock() {
|
||||
// Mock CreatePublicKeyCredentialRequest constructor to bypass JSON validation
|
||||
mockkConstructor(JSONObject::class)
|
||||
every { anyConstructed<JSONObject>().getJSONObject(any()) } returns mockk {
|
||||
every { getString("name") } returns "user"
|
||||
every { getString("displayName") } returns "user"
|
||||
every { isNull("displayName") } returns false
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardownConstructorMock() {
|
||||
unmockkConstructor(CreatePublicKeyCredentialRequest::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success without origin returns Success`() = runTest {
|
||||
val response = mockk<CreatePublicKeyCredentialResponse> {
|
||||
every { registrationResponseJson } returns """{"id":"test-credential-id"}"""
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.createPasskey(
|
||||
username = "user@example.com",
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("example.com") == true)
|
||||
assertTrue(result.data?.contains("Origin: null") == true)
|
||||
assertTrue(result.data?.contains("test-credential-id") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success with origin returns Success`() = runTest {
|
||||
val response = mockk<CreatePublicKeyCredentialResponse> {
|
||||
every { registrationResponseJson } returns """{"id":"test-credential-id"}"""
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.createPasskey(
|
||||
username = "user@example.com",
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("https://example.com") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation exception returns Cancelled`() = runTest {
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws CreateCredentialCancellationException("User cancelled")
|
||||
|
||||
val result = manager.createPasskey("user", "example.com", null)
|
||||
|
||||
assertTrue(result is CredentialTestResult.Cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create exception returns Error`() = runTest {
|
||||
val exception = mockk<CreateCredentialException>(relaxed = true) {
|
||||
every { message } returns "Failed to create passkey"
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws exception
|
||||
|
||||
val result = manager.createPasskey("user", "example.com", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertEquals(exception, result.exception)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected response type returns Error`() = runTest {
|
||||
val unexpectedResponse = CreatePasswordResponse()
|
||||
coEvery {
|
||||
credentialManager.createCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns unexpectedResponse
|
||||
|
||||
val result = manager.createPasskey("user", "example.com", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertTrue(result.exception is IllegalStateException)
|
||||
assertTrue(
|
||||
result.exception?.message?.contains("Unexpected response type") == true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class GetPasskey {
|
||||
|
||||
@Test
|
||||
fun `success without origin returns Success`() = runTest {
|
||||
val credential = mockk<PublicKeyCredential> {
|
||||
every { authenticationResponseJson } returns """{"id":"auth-response"}"""
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { this@mockk.credential } returns credential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasskey(rpId = "example.com", origin = null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("example.com") == true)
|
||||
assertTrue(result.data?.contains("Origin: null") == true)
|
||||
assertTrue(result.data?.contains("auth-response") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success with origin returns Success`() = runTest {
|
||||
val credential = mockk<PublicKeyCredential> {
|
||||
every { authenticationResponseJson } returns """{"id":"auth-response"}"""
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { this@mockk.credential } returns credential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("https://example.com") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation exception returns Cancelled`() = runTest {
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws GetCredentialCancellationException("User cancelled")
|
||||
|
||||
val result = manager.getPasskey("example.com", null)
|
||||
|
||||
assertTrue(result is CredentialTestResult.Cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get exception returns Error`() = runTest {
|
||||
val exception = mockk<GetCredentialException>(relaxed = true) {
|
||||
every { message } returns "Failed to authenticate"
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws exception
|
||||
|
||||
val result = manager.getPasskey("example.com", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertEquals(exception, result.exception)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected credential type returns Error`() = runTest {
|
||||
val unexpectedCredential = mockk<PasswordCredential>()
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { credential } returns unexpectedCredential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasskey("example.com", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertTrue(result.exception is IllegalStateException)
|
||||
assertTrue(
|
||||
result.exception?.message?.contains("Unexpected credential type") == true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class GetPasswordOrPasskey {
|
||||
|
||||
@Test
|
||||
fun `success with PasswordCredential without origin returns Success`() = runTest {
|
||||
val credential = mockk<PasswordCredential> {
|
||||
every { id } returns "user@example.com"
|
||||
every { password } returns "SecurePass123!"
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { this@mockk.credential } returns credential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasswordOrPasskey(rpId = "example.com", origin = null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("Type: PASSWORD") == true)
|
||||
assertTrue(result.data?.contains("user@example.com") == true)
|
||||
assertTrue(result.data?.contains("SecurePass123!") == true)
|
||||
assertTrue(result.data?.contains("Origin: null") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success with PasswordCredential with origin returns Success`() = runTest {
|
||||
val credential = mockk<PasswordCredential> {
|
||||
every { id } returns "user@example.com"
|
||||
every { password } returns "SecurePass123!"
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { this@mockk.credential } returns credential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("https://example.com") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success with PublicKeyCredential without origin returns Success`() = runTest {
|
||||
val credential = mockk<PublicKeyCredential> {
|
||||
every { authenticationResponseJson } returns """{"id":"passkey-auth"}"""
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { this@mockk.credential } returns credential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasswordOrPasskey(rpId = "example.com", origin = null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("Type: PASSKEY") == true)
|
||||
assertTrue(result.data?.contains("Origin: null") == true)
|
||||
assertTrue(result.data?.contains("passkey-auth") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success with PublicKeyCredential with origin returns Success`() = runTest {
|
||||
val credential = mockk<PublicKeyCredential> {
|
||||
every { authenticationResponseJson } returns """{"id":"passkey-auth"}"""
|
||||
}
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { this@mockk.credential } returns credential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Success -> {
|
||||
assertTrue(result.data?.contains("https://example.com") == true)
|
||||
}
|
||||
|
||||
else -> fail("Expected Success but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation exception returns Cancelled`() = runTest {
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws GetCredentialCancellationException("User cancelled")
|
||||
|
||||
val result = manager.getPasswordOrPasskey("example.com", null)
|
||||
|
||||
assertTrue(result is CredentialTestResult.Cancelled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get exception returns Error`() = runTest {
|
||||
val exception = mockk<GetCredentialException>(relaxed = true) {
|
||||
every { message } returns "Failed to get credential"
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} throws exception
|
||||
|
||||
val result = manager.getPasswordOrPasskey("example.com", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertEquals(exception, result.exception)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected credential type returns Error`() = runTest {
|
||||
val unexpectedCredential = mockk<Credential>()
|
||||
val response = mockk<GetCredentialResponse> {
|
||||
every { credential } returns unexpectedCredential
|
||||
}
|
||||
coEvery {
|
||||
credentialManager.getCredential(
|
||||
context = any(),
|
||||
request = any(),
|
||||
)
|
||||
} returns response
|
||||
|
||||
val result = manager.getPasswordOrPasskey("example.com", null)
|
||||
|
||||
when (result) {
|
||||
is CredentialTestResult.Error -> {
|
||||
assertTrue(result.exception is IllegalStateException)
|
||||
assertTrue(
|
||||
result.exception?.message?.contains("Unexpected credential type") == true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> fail("Expected Error but got $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.autofill
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [AutofillPlaceholderScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the placeholder screen displays correctly and handles back navigation
|
||||
* following UDF patterns.
|
||||
*/
|
||||
class AutofillPlaceholderScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<AutofillPlaceholderEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(Unit)
|
||||
private val viewModel = mockk<AutofillPlaceholderViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
AutofillPlaceholderScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `placeholder text is displayed on screen`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Autofill testing functionality coming soon")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(AutofillPlaceholderAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(AutofillPlaceholderEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.autofill
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AutofillPlaceholderViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `BackClick action emits NavigateBack event`() = runTest {
|
||||
val viewModel = AutofillPlaceholderViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(AutofillPlaceholderAction.BackClick)
|
||||
|
||||
assertEquals(
|
||||
AutofillPlaceholderEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpasskey
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [CreatePasskeyScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Create Passkey screen displays input fields, buttons, and result area
|
||||
* correctly and handles user interactions appropriately following UDF patterns.
|
||||
*/
|
||||
class CreatePasskeyScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<CreatePasskeyEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
CreatePasskeyState(
|
||||
username = "",
|
||||
rpId = "",
|
||||
origin = "",
|
||||
isLoading = false,
|
||||
resultText = "Ready to create passkey credential.\n\n" +
|
||||
"Enter username and Relying Party ID, then click Execute.",
|
||||
),
|
||||
)
|
||||
private val viewModel = mockk<CreatePasskeyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
CreatePasskeyScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `username field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Username")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relying party id field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Relying Party ID")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `origin optional field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Origin (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Execute")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Clear")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `result field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Result")
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CreatePasskeyAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(CreatePasskeyEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,500 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpasskey
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class CreatePasskeyViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockCredentialTestManager = mockk<CredentialTestManager>()
|
||||
private val savedStateHandle = SavedStateHandle()
|
||||
|
||||
private fun createViewModel(): CreatePasskeyViewModel {
|
||||
return CreatePasskeyViewModel(
|
||||
credentialTestManager = mockCredentialTestManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
clock = CLOCK,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should have default values`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals("", state.username)
|
||||
assertEquals("", state.rpId)
|
||||
assertEquals("", state.origin)
|
||||
assertFalse(state.isLoading)
|
||||
assertTrue(state.resultText.contains("Ready to create passkey credential"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UsernameChanged action updates username in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
|
||||
val updatedState = viewModel.stateFlow.value
|
||||
assertEquals("test-user", updatedState.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RpIdChanged action updates rpId in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
val updatedState = viewModel.stateFlow.value
|
||||
assertEquals("example.com", updatedState.rpId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OriginChanged action updates origin in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
|
||||
|
||||
val updatedState = viewModel.stateFlow.value
|
||||
assertEquals("https://example.com", updatedState.origin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action with blank username shows validation error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set rpId but leave username blank
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("Validation Error"))
|
||||
assertTrue(
|
||||
resultState.resultText.contains("Username and Relying Party ID are required"),
|
||||
)
|
||||
|
||||
// Verify manager was never called
|
||||
coVerify(exactly = 0) {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action with blank rpId shows validation error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set username but leave rpId blank
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("Validation Error"))
|
||||
assertTrue(
|
||||
resultState.resultText.contains("Username and Relying Party ID are required"),
|
||||
)
|
||||
|
||||
// Verify manager was never called
|
||||
coVerify(exactly = 0) {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action with blank username and rpId shows validation error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("Validation Error"))
|
||||
assertTrue(
|
||||
resultState.resultText.contains("Username and Relying Party ID are required"),
|
||||
)
|
||||
|
||||
// Verify manager was never called
|
||||
coVerify(exactly = 0) {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action triggers passkey creation with required fields`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(
|
||||
username = "test-user",
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "test-passkey-data",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
// Wait for async operation to complete
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.createPasskey(
|
||||
username = "test-user",
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action triggers passkey creation with origin when provided`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(
|
||||
username = "test-user",
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "test-passkey-data",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set all fields including optional origin
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
// Wait for async operation to complete
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.createPasskey(
|
||||
username = "test-user",
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action with blank origin passes null to manager`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(
|
||||
username = "test-user",
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields and explicitly blank origin
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.OriginChanged(" ")) // Whitespace only
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
// Wait for async operation to complete
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.createPasskey(
|
||||
username = "test-user",
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action sets loading state to true`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} coAnswers {
|
||||
kotlinx.coroutines.delay(100)
|
||||
CredentialTestResult.Success()
|
||||
}
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1) // Skip initial state
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
awaitItem() // Consume username change state
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
awaitItem() // Consume rpId change state
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
// Should receive loading state
|
||||
val loadingState = awaitItem()
|
||||
assertTrue(loadingState.isLoading)
|
||||
assertTrue(loadingState.resultText.contains("Creating passkey credential"))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result updates state with success message`() = runTest {
|
||||
val successMessage = "Passkey created successfully"
|
||||
val testData = "credential_id: ABC123"
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = testData,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
assertTrue(resultState.resultText.contains(testData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result updates state with error message`() = runTest {
|
||||
val exception = Exception("Invalid relying party")
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = exception,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains("Invalid relying party"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled result updates state with cancelled message`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Cancelled
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("CANCELLED"))
|
||||
assertTrue(resultState.resultText.contains("User cancelled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearResultClick action resets result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ClearResultClick)
|
||||
|
||||
val clearedState = viewModel.stateFlow.value
|
||||
assertEquals("Result cleared.\n", clearedState.resultText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearResultClick preserves input field values`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set field values
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ClearResultClick)
|
||||
|
||||
val clearedState = viewModel.stateFlow.value
|
||||
assertEquals("Result cleared.\n", clearedState.resultText)
|
||||
assertEquals("test-user", clearedState.username)
|
||||
assertEquals("example.com", clearedState.rpId)
|
||||
assertEquals("https://example.com", clearedState.origin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result without exception does not crash`() = runTest {
|
||||
val errorMessage = "Unknown error"
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains(errorMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result without data does not crash`() = runTest {
|
||||
val successMessage = "Passkey created"
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is persisted to SavedStateHandle`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
// Wait for state to update
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val persistedState = savedStateHandle.get<CreatePasskeyState>("state")
|
||||
assertEquals(viewModel.stateFlow.value, persistedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple field updates are all persisted`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("user1"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("user2"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
// Wait for state updates
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val persistedState = savedStateHandle.get<CreatePasskeyState>("state")
|
||||
assertEquals("user2", persistedState?.username)
|
||||
assertEquals("example.com", persistedState?.rpId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validation error message appends to existing result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val initialText = viewModel.stateFlow.value.resultText
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
|
||||
val errorState = viewModel.stateFlow.value
|
||||
assertTrue(errorState.resultText.contains(initialText))
|
||||
assertTrue(errorState.resultText.contains("Validation Error"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple operations append to result text`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "First operation",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set required fields
|
||||
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
|
||||
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
// First operation
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPasskey(any(), any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "Second operation",
|
||||
)
|
||||
|
||||
// Second operation
|
||||
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val finalState = viewModel.stateFlow.value
|
||||
assertTrue(finalState.resultText.contains("First operation"))
|
||||
assertTrue(finalState.resultText.contains("Second operation"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick action sends NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasskeyAction.BackClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
CreatePasskeyEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CLOCK = Clock.fixed(
|
||||
Instant.parse("2024-10-12T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
@ -0,0 +1,103 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpassword
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [CreatePasswordScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Create Password screen displays all UI elements correctly
|
||||
* and handles user interactions appropriately following UDF patterns.
|
||||
*/
|
||||
class CreatePasswordScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<CreatePasswordEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
CreatePasswordState(
|
||||
username = "",
|
||||
password = "",
|
||||
isLoading = false,
|
||||
resultText = "Ready to create password credential.\n\n" +
|
||||
"Enter username and password, then click Execute.",
|
||||
),
|
||||
)
|
||||
private val viewModel = mockk<CreatePasswordViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
CreatePasswordScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `username field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Username")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Password")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Execute")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Clear")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `result field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Result")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CreatePasswordAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(CreatePasswordEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,471 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.createpassword
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class CreatePasswordViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockCredentialTestManager = mockk<CredentialTestManager>()
|
||||
|
||||
@Test
|
||||
fun `initial state should have default values`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertFalse(state.isLoading)
|
||||
assertEquals("", state.username)
|
||||
assertEquals("", state.password)
|
||||
assertTrue(state.resultText.contains("Ready to create password credential"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UsernameChanged action updates username state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val testUsername = "test@example.com"
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// Verify the username is initially empty
|
||||
assertEquals("", awaitItem().username)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.UsernameChanged(testUsername))
|
||||
|
||||
val updatedState = awaitItem()
|
||||
assertEquals(testUsername, updatedState.username)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PasswordChanged action updates password state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// Verify the password is initially empty
|
||||
assertEquals("", awaitItem().password)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.PasswordChanged(testPassword))
|
||||
|
||||
val updatedState = awaitItem()
|
||||
assertEquals(testPassword, updatedState.password)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank username shows validation error`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = "",
|
||||
password = "password123",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(
|
||||
resultState.resultText.contains(
|
||||
"Validation Error: Username and password are required",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank password shows validation error`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = "test@example.com",
|
||||
password = "",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(
|
||||
resultState.resultText.contains(
|
||||
"Validation Error: Username and password are required",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank username and password shows validation error`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = "",
|
||||
password = "",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("Validation Error"))
|
||||
assertTrue(resultState.resultText.contains("Username and password are required"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with valid inputs triggers password creation`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "test-credential-data",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Set username and password
|
||||
viewModel.trySendAction(CreatePasswordAction.UsernameChanged(testUsername))
|
||||
viewModel.trySendAction(CreatePasswordAction.PasswordChanged(testPassword))
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action sets loading state to true`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
viewModel.stateFlow.test {
|
||||
|
||||
skipItems(1)
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
// Should receive loading state
|
||||
val loadingState = awaitItem()
|
||||
assertTrue(loadingState.isLoading)
|
||||
assertTrue(loadingState.resultText.contains("Creating password credential"))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result updates state with success message`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
val testData = "credential-id: 12345"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = testData,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(testData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result updates state with error message`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
val exception = Exception("Network timeout")
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = exception,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains("Network timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled result updates state with cancelled message`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Cancelled
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("CANCELLED"))
|
||||
assertTrue(resultState.resultText.contains("User cancelled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearResultClick action resets result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ClearResultClick)
|
||||
|
||||
val clearedState = viewModel.stateFlow.value
|
||||
assertEquals("Result cleared.\n", clearedState.resultText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result without exception does not crash`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result without data does not crash`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple input changes are reflected in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1) // Skip initial state
|
||||
|
||||
// Change username multiple times
|
||||
viewModel.trySendAction(CreatePasswordAction.UsernameChanged("first"))
|
||||
assertEquals("first", awaitItem().username)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.UsernameChanged("second"))
|
||||
assertEquals("second", awaitItem().username)
|
||||
|
||||
// Change password multiple times
|
||||
viewModel.trySendAction(CreatePasswordAction.PasswordChanged("pass1"))
|
||||
assertEquals("pass1", awaitItem().password)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.PasswordChanged("pass2"))
|
||||
assertEquals("pass2", awaitItem().password)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick does not trigger operation when validation fails`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Leave username and password blank
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
// Verify createPassword was never called
|
||||
coVerify(exactly = 0) {
|
||||
mockCredentialTestManager.createPassword(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `username with whitespace only fails validation`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = " ",
|
||||
password = "ValidPassword123!",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("Validation Error"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password with whitespace only fails validation`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = "valid@example.com",
|
||||
password = " ",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("Validation Error"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful operation preserves username and password in state`() = runTest {
|
||||
val testUsername = "test@example.com"
|
||||
val testPassword = "SecurePassword123!"
|
||||
|
||||
coEvery {
|
||||
mockCredentialTestManager.createPassword(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel(
|
||||
initialState = CreatePasswordState(
|
||||
username = testUsername,
|
||||
password = testPassword,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
// Verify username and password are still in state after operation
|
||||
assertEquals(testUsername, resultState.username)
|
||||
assertEquals(testPassword, resultState.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick action sends NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(CreatePasswordAction.BackClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
CreatePasswordEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
initialState: CreatePasswordState? = null,
|
||||
): CreatePasswordViewModel {
|
||||
return CreatePasswordViewModel(
|
||||
credentialTestManager = mockCredentialTestManager,
|
||||
savedStateHandle = SavedStateHandle().apply { this["state"] = initialState },
|
||||
clock = CLOCK,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val CLOCK = Clock.fixed(
|
||||
Instant.parse("2024-10-12T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
@ -0,0 +1,198 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [CredentialManagerListScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Credential Manager list screen displays navigation options correctly,
|
||||
* sends correct actions to the ViewModel when buttons are clicked, and invokes callbacks
|
||||
* when the ViewModel emits events, following UDF patterns.
|
||||
*/
|
||||
class CredentialManagerListScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
private var haveCalledOnNavigateToGetPassword = false
|
||||
private var haveCalledOnNavigateToCreatePassword = false
|
||||
private var haveCalledOnNavigateToGetPasskey = false
|
||||
private var haveCalledOnNavigateToCreatePasskey = false
|
||||
private var haveCalledOnNavigateToGetPasswordOrPasskey = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<CredentialManagerListEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(Unit)
|
||||
private val viewModel = mockk<CredentialManagerListViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
CredentialManagerListScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
onNavigateToGetPassword = { haveCalledOnNavigateToGetPassword = true },
|
||||
onNavigateToCreatePassword = { haveCalledOnNavigateToCreatePassword = true },
|
||||
onNavigateToGetPasskey = { haveCalledOnNavigateToGetPasskey = true },
|
||||
onNavigateToCreatePasskey = { haveCalledOnNavigateToCreatePasskey = true },
|
||||
onNavigateToGetPasswordOrPasskey = {
|
||||
haveCalledOnNavigateToGetPasswordOrPasskey = true
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get password button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Password")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create password button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Create Password")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get passkey button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Passkey")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create passkey button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Create Passkey")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get password or passkey button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Password or Passkey")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CredentialManagerListAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get password button click should send GetPasswordClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Password")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasswordClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create password button click should send CreatePasswordClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Create Password")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CredentialManagerListAction.CreatePasswordClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get passkey button click should send GetPasskeyClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Passkey")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasskeyClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create passkey button click should send CreatePasskeyClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Create Passkey")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CredentialManagerListAction.CreatePasskeyClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get password or passkey button click should send GetPasswordOrPasskeyClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Password or Passkey")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasswordOrPasskeyClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToGetPassword event should call onNavigateToGetPassword`() {
|
||||
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToGetPassword)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToGetPassword)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToCreatePassword event should call onNavigateToCreatePassword`() {
|
||||
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToCreatePassword)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToCreatePassword)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToGetPasskey event should call onNavigateToGetPasskey`() {
|
||||
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToGetPasskey)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToGetPasskey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToCreatePasskey event should call onNavigateToCreatePasskey`() {
|
||||
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToCreatePasskey)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToCreatePasskey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToGetPasswordOrPasskey event should call onNavigateToGetPasswordOrPasskey`() {
|
||||
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToGetPasswordOrPasskey)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToGetPasswordOrPasskey)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CredentialManagerListViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `initial state is Unit`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(Unit, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GetPasswordClick action emits NavigateToGetPassword event`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasswordClick)
|
||||
|
||||
assertEquals(
|
||||
CredentialManagerListEvent.NavigateToGetPassword,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CreatePasswordClick action emits NavigateToCreatePassword event`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CredentialManagerListAction.CreatePasswordClick)
|
||||
|
||||
assertEquals(
|
||||
CredentialManagerListEvent.NavigateToCreatePassword,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GetPasskeyClick action emits NavigateToGetPasskey event`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasskeyClick)
|
||||
|
||||
assertEquals(
|
||||
CredentialManagerListEvent.NavigateToGetPasskey,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CreatePasskeyClick action emits NavigateToCreatePasskey event`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CredentialManagerListAction.CreatePasskeyClick)
|
||||
|
||||
assertEquals(
|
||||
CredentialManagerListEvent.NavigateToCreatePasskey,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GetPasswordOrPasskeyClick action emits NavigateToGetPasswordOrPasskey event`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CredentialManagerListAction.GetPasswordOrPasskeyClick)
|
||||
|
||||
assertEquals(
|
||||
CredentialManagerListEvent.NavigateToGetPasswordOrPasskey,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick action emits NavigateBack event`() = runTest {
|
||||
val viewModel = CredentialManagerListViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CredentialManagerListAction.BackClick)
|
||||
|
||||
assertEquals(
|
||||
CredentialManagerListEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasskey
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [GetPasskeyScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Get Passkey screen displays input fields, buttons, and result area
|
||||
* correctly and handles user interactions appropriately following UDF patterns.
|
||||
*/
|
||||
class GetPasskeyScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<GetPasskeyEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
GetPasskeyState(
|
||||
rpId = "",
|
||||
origin = "",
|
||||
isLoading = false,
|
||||
resultText = "Ready to authenticate passkey.\n\n" +
|
||||
"Enter Relying Party ID, then click Execute.",
|
||||
),
|
||||
)
|
||||
private val viewModel = mockk<GetPasskeyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
GetPasskeyScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relying party id field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Relying Party ID")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `origin optional field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Origin (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Execute")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Clear")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `result field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Result")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(GetPasskeyAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(GetPasskeyEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,320 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasskey
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class GetPasskeyViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockCredentialTestManager = mockk<CredentialTestManager>()
|
||||
private val savedStateHandle = SavedStateHandle()
|
||||
|
||||
private fun createViewModel(): GetPasskeyViewModel {
|
||||
return GetPasskeyViewModel(
|
||||
credentialTestManager = mockCredentialTestManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
clock = CLOCK,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should have default values`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals("", state.rpId)
|
||||
assertEquals("", state.origin)
|
||||
assertFalse(state.isLoading)
|
||||
assertTrue(state.resultText.contains("Ready to authenticate passkey"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RpIdChanged action updates rpId in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
val updatedState = viewModel.stateFlow.value
|
||||
assertEquals("example.com", updatedState.rpId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OriginChanged action updates origin in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.OriginChanged("https://example.com"))
|
||||
|
||||
val updatedState = viewModel.stateFlow.value
|
||||
assertEquals("https://example.com", updatedState.origin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank rpId shows validation error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
val errorState = viewModel.stateFlow.value
|
||||
assertFalse(errorState.isLoading)
|
||||
assertTrue(errorState.resultText.contains("Validation Error"))
|
||||
assertTrue(errorState.resultText.contains("Relying Party ID is required"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with valid rpId triggers passkey authentication`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "test-passkey-data",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with rpId and origin passes both parameters`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.OriginChanged("https://example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://example.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank origin passes null origin`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.OriginChanged(""))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action sets loading state to true`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} coAnswers {
|
||||
kotlinx.coroutines.delay(100)
|
||||
CredentialTestResult.Success()
|
||||
}
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1) // Skip initial state
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
skipItems(1) // Skip rpId update
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
// Should receive loading state
|
||||
val loadingState = awaitItem()
|
||||
assertTrue(loadingState.isLoading)
|
||||
assertTrue(loadingState.resultText.contains("Starting passkey authentication"))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result updates state with success message`() = runTest {
|
||||
val successMessage = "Passkey authenticated successfully"
|
||||
val testData = "credential-id: test-credential-123"
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = testData,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
assertTrue(resultState.resultText.contains(testData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result updates state with error message`() = runTest {
|
||||
val exception = Exception("Invalid passkey")
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = exception,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains("Invalid passkey"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled result updates state with cancelled message`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} returns CredentialTestResult.Cancelled
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("CANCELLED"))
|
||||
assertTrue(resultState.resultText.contains("User cancelled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearResultClick action resets result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.ClearResultClick)
|
||||
|
||||
val clearedState = viewModel.stateFlow.value
|
||||
assertEquals("Result cleared.\n", clearedState.resultText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result without exception does not crash`() = runTest {
|
||||
val errorMessage = "Unknown error"
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains(errorMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result without data does not crash`() = runTest {
|
||||
val successMessage = "Passkey authenticated"
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is persisted to SavedStateHandle`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
|
||||
|
||||
// Wait for state to update
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val persistedState = savedStateHandle.get<GetPasskeyState>("state")
|
||||
assertEquals(viewModel.stateFlow.value, persistedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick action sends NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasskeyAction.BackClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
GetPasskeyEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CLOCK = Clock.fixed(
|
||||
Instant.parse("2024-10-12T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
@ -0,0 +1,94 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpassword
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [GetPasswordScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Get Password screen displays text and controls correctly
|
||||
* and handles user interactions appropriately following UDF patterns.
|
||||
*/
|
||||
class GetPasswordScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<GetPasswordEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
GetPasswordState(
|
||||
isLoading = false,
|
||||
resultText = "Ready to retrieve password.\n\n" +
|
||||
"Click Execute to open the password picker.",
|
||||
),
|
||||
)
|
||||
private val viewModel = mockk<GetPasswordViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
GetPasswordScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `helper text is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("No inputs required. Click Execute to open credential picker.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Execute")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear button is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Clear")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `result field is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Result")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(GetPasswordAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(GetPasswordEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpassword
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class GetPasswordViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockCredentialTestManager = mockk<CredentialTestManager>()
|
||||
private val savedStateHandle = SavedStateHandle()
|
||||
|
||||
private fun createViewModel(): GetPasswordViewModel {
|
||||
return GetPasswordViewModel(
|
||||
credentialTestManager = mockCredentialTestManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
clock = CLOCK,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should have default values`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertFalse(state.isLoading)
|
||||
assertTrue(state.resultText.contains("Ready to retrieve password"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action triggers password retrieval`() = runTest {
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success(
|
||||
data = "test-password-data",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
coVerify { mockCredentialTestManager.getPassword() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action sets loading state to true`() = runTest {
|
||||
coEvery { mockCredentialTestManager.getPassword() } coAnswers {
|
||||
kotlinx.coroutines.delay(100)
|
||||
CredentialTestResult.Success()
|
||||
}
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1) // Skip initial state
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
// Should receive loading state
|
||||
val loadingState = awaitItem()
|
||||
assertTrue(loadingState.isLoading)
|
||||
assertTrue(loadingState.resultText.contains("Starting password retrieval"))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result updates state with success message`() = runTest {
|
||||
val successMessage = "Password retrieved successfully"
|
||||
val testData = "username: test@example.com"
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success(
|
||||
data = testData,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
assertTrue(resultState.resultText.contains(testData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result updates state with error message`() = runTest {
|
||||
val exception = Exception("Network error")
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Error(
|
||||
exception = exception,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains("Network error"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled result updates state with cancelled message`() = runTest {
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Cancelled
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("CANCELLED"))
|
||||
assertTrue(resultState.resultText.contains("User cancelled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearResultClick action resets result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ClearResultClick)
|
||||
|
||||
val clearedState = viewModel.stateFlow.value
|
||||
assertEquals("Result cleared.\n", clearedState.resultText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result without exception does not crash`() = runTest {
|
||||
val errorMessage = "Unknown error"
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Error(
|
||||
exception = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains(errorMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result without data does not crash`() = runTest {
|
||||
val successMessage = "Password retrieved"
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success(
|
||||
data = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is persisted to SavedStateHandle`() = runTest {
|
||||
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
|
||||
|
||||
// Wait for state to update
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val persistedState = savedStateHandle.get<GetPasswordState>("state")
|
||||
assertEquals(viewModel.stateFlow.value, persistedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick action sends NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordAction.BackClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
GetPasswordEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CLOCK = Clock.fixed(
|
||||
Instant.parse("2024-10-12T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
@ -0,0 +1,104 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [GetPasswordOrPasskeyScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Get Password or Passkey screen displays UI elements correctly
|
||||
* and handles user interactions appropriately following UDF patterns.
|
||||
*/
|
||||
class GetPasswordOrPasskeyScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateBack = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<GetPasswordOrPasskeyEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(
|
||||
GetPasswordOrPasskeyState(
|
||||
rpId = "",
|
||||
origin = "",
|
||||
isLoading = false,
|
||||
resultText = "Ready to retrieve password or passkey.\n\n" +
|
||||
"Enter Relying Party ID, then click Execute.\n" +
|
||||
"System picker will show both passwords and passkeys.",
|
||||
),
|
||||
)
|
||||
private val viewModel = mockk<GetPasswordOrPasskeyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
GetPasswordOrPasskeyScreen(
|
||||
onNavigateBack = { haveCalledOnNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relying party id field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Relying Party ID")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `origin optional field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Origin (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Execute")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear button label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Clear")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `result field label is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Result")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.BackClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(GetPasswordOrPasskeyEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledOnNavigateBack)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,414 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.testharness.data.manager.CredentialTestManager
|
||||
import com.bitwarden.testharness.data.model.CredentialTestResult
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class GetPasswordOrPasskeyViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockCredentialTestManager = mockk<CredentialTestManager>()
|
||||
private val savedStateHandle = SavedStateHandle()
|
||||
|
||||
private fun createViewModel(): GetPasswordOrPasskeyViewModel {
|
||||
return GetPasswordOrPasskeyViewModel(
|
||||
credentialTestManager = mockCredentialTestManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
clock = CLOCK,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should have default values`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals("", state.rpId)
|
||||
assertEquals("", state.origin)
|
||||
assertFalse(state.isLoading)
|
||||
assertTrue(state.resultText.contains("Ready to retrieve password or passkey"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RpIdChanged action updates rpId in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals("example.com", state.rpId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OriginChanged action updates origin in state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged("https://app.example.com"))
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals("https://app.example.com", state.origin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank rpId shows validation error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val errorState = viewModel.stateFlow.value
|
||||
assertFalse(errorState.isLoading)
|
||||
assertTrue(errorState.resultText.contains("Validation Error"))
|
||||
assertTrue(errorState.resultText.contains("Relying Party ID is required"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank rpId does not call credentialTestManager`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { mockCredentialTestManager.getPasswordOrPasskey(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with valid rpId triggers credential retrieval`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success(
|
||||
data = "test-credential-data",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with valid rpId and origin passes both parameters`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://app.example.com",
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged("https://app.example.com"))
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = "https://app.example.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick with blank origin passes null to credentialTestManager`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged(""))
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExecuteClick action sets loading state to true`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} coAnswers {
|
||||
kotlinx.coroutines.delay(100)
|
||||
CredentialTestResult.Success()
|
||||
}
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
skipItems(1) // Skip initial state
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
awaitItem() // Consume rpId change
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
// Should receive loading state
|
||||
val loadingState = awaitItem()
|
||||
assertTrue(loadingState.isLoading)
|
||||
assertTrue(loadingState.resultText.contains("Starting credential retrieval"))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result updates state with success message`() = runTest {
|
||||
val successMessage = "Credential retrieved successfully"
|
||||
val testData = "type: password, username: test@example.com"
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = testData,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
assertTrue(resultState.resultText.contains(testData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result updates state with error message`() = runTest {
|
||||
val exception = Exception("Network error")
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = exception,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains("Network error"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancelled result updates state with cancelled message`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Cancelled
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("CANCELLED"))
|
||||
assertTrue(resultState.resultText.contains("User cancelled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearResultClick action resets result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ClearResultClick)
|
||||
|
||||
val clearedState = viewModel.stateFlow.value
|
||||
assertEquals("Result cleared.\n", clearedState.resultText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error result without exception does not crash`() = runTest {
|
||||
val errorMessage = "Unknown error"
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Error(
|
||||
exception = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("ERROR"))
|
||||
assertTrue(resultState.resultText.contains(errorMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success result without data does not crash`() = runTest {
|
||||
val successMessage = "Credential retrieved"
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success(
|
||||
data = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val resultState = viewModel.stateFlow.value
|
||||
assertFalse(resultState.isLoading)
|
||||
assertTrue(resultState.resultText.contains("SUCCESS"))
|
||||
assertTrue(resultState.resultText.contains(successMessage))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is persisted to SavedStateHandle`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
// Wait for state to update
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val persistedState = savedStateHandle.get<GetPasswordOrPasskeyState>("state")
|
||||
assertEquals(viewModel.stateFlow.value, persistedState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple input changes update state correctly`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged("https://app.example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("newdomain.com"))
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals("newdomain.com", state.rpId)
|
||||
assertEquals("https://app.example.com", state.origin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validation error appends to existing result text`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// First set some existing result text
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ClearResultClick)
|
||||
|
||||
// Try to execute without rpId
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
val errorState = viewModel.stateFlow.value
|
||||
assertTrue(errorState.resultText.contains("Result cleared"))
|
||||
assertTrue(errorState.resultText.contains("Validation Error"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful execution after validation error works correctly`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// First try without rpId - should fail validation
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
// Now set rpId and try again - should succeed
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whitespace-only origin is treated as null`() = runTest {
|
||||
coEvery {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
} returns CredentialTestResult.Success()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged(" "))
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
mockCredentialTestManager.getPasswordOrPasskey(
|
||||
rpId = "example.com",
|
||||
origin = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick action sends NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GetPasswordOrPasskeyAction.BackClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
GetPasswordOrPasskeyEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CLOCK = Clock.fixed(
|
||||
Instant.parse("2024-10-12T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
@ -0,0 +1,96 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.landing
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [LandingScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that the Landing screen displays navigation options correctly
|
||||
* and handles user interactions appropriately following UDF patterns.
|
||||
*/
|
||||
class LandingScreenTest : BaseComposeTest() {
|
||||
private var haveCalledOnNavigateToAutofill = false
|
||||
private var haveCalledOnNavigateToCredentialManager = false
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<LandingEvent>(replay = 1)
|
||||
private val mutableStateFlow = MutableStateFlow(Unit)
|
||||
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
LandingScreen(
|
||||
onNavigateToAutofill = { haveCalledOnNavigateToAutofill = true },
|
||||
onNavigateToCredentialManager = { haveCalledOnNavigateToCredentialManager = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test categories header text is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("TEST CATEGORIES")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autofill button text is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Autofill")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credential manager button text is displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Credential Manager")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autofill button click should send OnAutofillClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Autofill")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(LandingAction.OnAutofillClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credential manager button click should send OnCredentialManagerClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Credential Manager")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(LandingAction.OnCredentialManagerClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToAutofill event should call onNavigateToAutofill`() {
|
||||
mutableEventFlow.tryEmit(LandingEvent.NavigateToAutofill)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToAutofill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToCredentialManager event should call onNavigateToCredentialManager`() {
|
||||
mutableEventFlow.tryEmit(LandingEvent.NavigateToCredentialManager)
|
||||
|
||||
assertTrue(haveCalledOnNavigateToCredentialManager)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.landing
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class LandingViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `initial state should be Unit`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
|
||||
assertEquals(Unit, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state flow emits correct Unit state`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val state = awaitItem()
|
||||
assertEquals(Unit, state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OnAutofillClick action emits NavigateToAutofill event`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LandingAction.OnAutofillClick)
|
||||
assertEquals(LandingEvent.NavigateToAutofill, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OnCredentialManagerClick action emits NavigateToCredentialManager event`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LandingAction.OnCredentialManagerClick)
|
||||
assertEquals(LandingEvent.NavigateToCredentialManager, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event flow emits events correctly when multiple actions sent`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(LandingAction.OnAutofillClick)
|
||||
assertEquals(LandingEvent.NavigateToAutofill, awaitItem())
|
||||
|
||||
viewModel.actionChannel.trySend(LandingAction.OnCredentialManagerClick)
|
||||
assertEquals(LandingEvent.NavigateToCredentialManager, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event flow remains empty when no actions are sent`() = runTest {
|
||||
val viewModel = LandingViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.rootnav
|
||||
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.bitwarden.ui.platform.base.createMockNavHostController
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the [RootNavScreen] composable in the testharness module.
|
||||
*
|
||||
* Verifies that root-level navigation correctly routes to different test flows
|
||||
* based on the [RootNavState].
|
||||
*/
|
||||
class RootNavScreenTest : BaseComposeTest() {
|
||||
private val mockNavHostController = createMockNavHostController()
|
||||
private val rootNavStateFlow = MutableStateFlow<RootNavState>(RootNavState.Splash)
|
||||
private val viewModel = mockk<RootNavViewModel> {
|
||||
every { eventFlow } returns emptyFlow()
|
||||
every { stateFlow } returns rootNavStateFlow
|
||||
}
|
||||
|
||||
private var isSplashScreenRemoved: Boolean = false
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setTestContent {
|
||||
RootNavScreen(
|
||||
viewModel = viewModel,
|
||||
navController = mockNavHostController,
|
||||
onSplashScreenRemoved = { isSplashScreenRemoved = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state does not invoke splash screen removal`() {
|
||||
composeTestRule.runOnIdle {
|
||||
assert(!isSplashScreenRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splash screen callback is called when transitioning from Splash state`() {
|
||||
composeTestRule.runOnIdle {
|
||||
assert(!isSplashScreenRemoved)
|
||||
}
|
||||
|
||||
// Transition to Landing state
|
||||
rootNavStateFlow.value = RootNavState.Landing
|
||||
composeTestRule.runOnIdle {
|
||||
assert(isSplashScreenRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nav host displays landing destination as start destination`() {
|
||||
composeTestRule.runOnIdle {
|
||||
// The NavHost is configured with LandingRoute as startDestination
|
||||
assert(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nav host contains autofill and credential manager graphs`() {
|
||||
composeTestRule.runOnIdle {
|
||||
// The NavHost contains:
|
||||
// - landingDestination()
|
||||
// - autofillGraph()
|
||||
// - credentialManagerGraph()
|
||||
// Navigation to these graphs is triggered by user interaction in LandingScreen
|
||||
assert(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.bitwarden.testharness.ui.platform.feature.rootnav
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RootNavViewModelTest : BaseViewModelTest() {
|
||||
|
||||
@Test
|
||||
fun `initial state should transition from Splash to Landing on init`() = runTest {
|
||||
val viewModel = RootNavViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// First emission is Landing (after init block updates from Splash)
|
||||
assertEquals(RootNavState.Landing, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state flow emits correct Landing state`() = runTest {
|
||||
val viewModel = RootNavViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val state = awaitItem()
|
||||
assertEquals(RootNavState.Landing, state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event flow remains empty when no events are emitted`() = runTest {
|
||||
val viewModel = RootNavViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
// Event flow should not emit anything since no actions produce events
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is Landing after ViewModel construction`() = runTest {
|
||||
val viewModel = RootNavViewModel()
|
||||
|
||||
// Verify the current state value directly
|
||||
assertEquals(RootNavState.Landing, viewModel.stateFlow.value)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user