BIT-809: Generate fingerprint on Login with Device (#781)

This commit is contained in:
Caleb Derosier 2024-01-25 12:26:43 -07:00 committed by Álison Fernandes
parent cd020f2af9
commit 3635d368f9
18 changed files with 396 additions and 39 deletions

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.crypto.HashPurpose
@ -10,6 +11,21 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
* Source of authentication information and functionality from the Bitwarden SDK.
*/
interface AuthSdkSource {
/**
* Gets the data needed to create a new auth request.
*/
suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse>
/**
* Gets the fingerprint phrase for this [email] and [publicKey].
*/
suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String>
/**
* Creates a hashed password provided the given [email], [password], [kdf], and [purpose].
*/
@ -21,7 +37,7 @@ interface AuthSdkSource {
): Result<String>
/**
* Creates a set of encryption key information for registraation pers
* Creates a set of encryption key information for registration.
*/
suspend fun makeRegisterKeys(
email: String,

View file

@ -1,10 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.sdk.ClientAuth
import com.bitwarden.sdk.ClientPlatform
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
@ -15,8 +18,29 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
*/
class AuthSdkSourceImpl(
private val clientAuth: ClientAuth,
private val clientPlatform: ClientPlatform,
) : AuthSdkSource {
override suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse> = runCatching {
clientAuth.newAuthRequest(
email = email,
)
}
override suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String> = runCatching {
clientPlatform.fingerprint(
req = FingerprintRequest(
fingerprintMaterial = email,
publicKey = publicKey,
),
)
}
override suspend fun hashPassword(
email: String,
password: String,

View file

@ -20,5 +20,8 @@ object AuthSdkModule {
@Singleton
fun provideAuthSdkSource(
client: Client,
): AuthSdkSource = AuthSdkSourceImpl(clientAuth = client.auth())
): AuthSdkSource = AuthSdkSourceImpl(
clientAuth = client.auth(),
clientPlatform = client.platform(),
)
}

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
@ -136,6 +137,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
suspend fun getAuthRequests(): AuthRequestsResult
/**
* Gets a unique fingerprint phrase for this user.
*/
suspend fun getFingerprintPhrase(email: String): UserFingerprintResult
/**
* Get a [Boolean] indicating whether this is a known device.
*/

View file

@ -33,6 +33,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@ -410,6 +411,7 @@ class AuthRepositoryImpl(
is PasswordHintResponseJson.Error -> {
PasswordHintResult.Error(it.errorMessage)
}
PasswordHintResponseJson.Success -> PasswordHintResult.Success
}
},
@ -466,6 +468,22 @@ class AuthRepositoryImpl(
},
)
override suspend fun getFingerprintPhrase(
email: String,
): UserFingerprintResult =
authSdkSource.getNewAuthRequest(email)
.flatMap { requestResponse ->
authSdkSource
.getUserFingerprint(
email = email,
publicKey = requestResponse.publicKey,
)
}
.fold(
onFailure = { UserFingerprintResult.Error },
onSuccess = { UserFingerprintResult.Success(it) },
)
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of getting the user fingerprint.
*/
sealed class UserFingerprintResult {
/**
* Contains the user fingerprint.
*/
data class Success(
val fingerprint: String,
) : UserFingerprintResult()
/**
* There was an error getting the user fingerprint.
*/
data object Error : UserFingerprintResult()
}

View file

@ -69,7 +69,11 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
)
},
onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() },
onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() },
onNavigateToLoginWithDevice = { emailAddress ->
navController.navigateToLoginWithDevice(
emailAddress = emailAddress,
)
},
onNavigateToTwoFactorLogin = { navController.navigateToTwoFactorLogin() },
)
loginWithDeviceDestination(

View file

@ -45,7 +45,7 @@ fun NavGraphBuilder.loginDestination(
onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit,
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
onNavigateToTwoFactorLogin: () -> Unit,
) {
composableWithSlideTransitions(

View file

@ -61,12 +61,12 @@ import kotlinx.collections.immutable.toImmutableList
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
@Suppress("LongMethod", "LongParameterList")
fun LoginScreen(
onNavigateBack: () -> Unit,
onNavigateToMasterPasswordHint: (String) -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit,
onNavigateToLoginWithDevice: (emailAddress: String) -> Unit,
onNavigateToTwoFactorLogin: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
@ -85,7 +85,10 @@ fun LoginScreen(
}
LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn()
LoginEvent.NavigateToLoginWithDevice -> onNavigateToLoginWithDevice()
is LoginEvent.NavigateToLoginWithDevice -> {
onNavigateToLoginWithDevice(event.emailAddress)
}
LoginEvent.NavigateToTwoFactorLogin -> onNavigateToTwoFactorLogin()
is LoginEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()

View file

@ -217,7 +217,7 @@ class LoginViewModel @Inject constructor(
}
private fun handleLoginWithDeviceButtonClicked() {
sendEvent(LoginEvent.NavigateToLoginWithDevice)
sendEvent(LoginEvent.NavigateToLoginWithDevice(state.emailAddress))
}
private fun attemptLogin() {
@ -310,7 +310,9 @@ sealed class LoginEvent {
/**
* Navigates to the login with device screen.
*/
data object NavigateToLoginWithDevice : LoginEvent()
data class NavigateToLoginWithDevice(
val emailAddress: String,
) : LoginEvent()
/**
* Navigates to the two-factor login screen.

View file

@ -1,17 +1,34 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val LOGIN_WITH_DEVICE_ROUTE = "login_with_device"
private const val EMAIL_ADDRESS: String = "email_address"
private const val LOGIN_WITH_DEVICE_PREFIX = "login_with_device"
private const val LOGIN_WITH_DEVICE_ROUTE = "$LOGIN_WITH_DEVICE_PREFIX/{$EMAIL_ADDRESS}"
/**
* Class to retrieve login with device arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class LoginWithDeviceArgs(val emailAddress: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
)
}
/**
* Navigate to the Login with Device screen.
*/
fun NavController.navigateToLoginWithDevice(navOptions: NavOptions? = null) {
this.navigate(LOGIN_WITH_DEVICE_ROUTE, navOptions)
fun NavController.navigateToLoginWithDevice(
emailAddress: String,
navOptions: NavOptions? = null,
) {
this.navigate("$LOGIN_WITH_DEVICE_PREFIX/$emailAddress", navOptions)
}
/**

View file

@ -2,10 +2,16 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -16,19 +22,20 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class LoginWithDeviceViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginWithDeviceState, LoginWithDeviceEvent, LoginWithDeviceAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginWithDeviceState(
emailAddress = LoginWithDeviceArgs(savedStateHandle).emailAddress,
viewState = LoginWithDeviceState.ViewState.Loading,
),
) {
init {
mutableStateFlow.update {
// TODO BIT-809: Pull phrase from SDK
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
viewModelScope.launch {
trySendAction(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = authRepository.getFingerprintPhrase(state.emailAddress),
),
)
}
@ -39,6 +46,10 @@ class LoginWithDeviceViewModel @Inject constructor(
LoginWithDeviceAction.CloseButtonClick -> handleCloseButtonClicked()
LoginWithDeviceAction.ResendNotificationClick -> handleResendNotificationClicked()
LoginWithDeviceAction.ViewAllLogInOptionsClick -> handleViewAllLogInOptionsClicked()
is LoginWithDeviceAction.Internal.FingerprintPhraseReceived -> {
handleFingerprintPhraseReceived(action)
}
}
}
@ -54,6 +65,32 @@ class LoginWithDeviceViewModel @Inject constructor(
private fun handleViewAllLogInOptionsClicked() {
sendEvent(LoginWithDeviceEvent.NavigateBack)
}
private fun handleFingerprintPhraseReceived(
action: LoginWithDeviceAction.Internal.FingerprintPhraseReceived,
) {
when (action.result) {
is UserFingerprintResult.Success -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = action.result.fingerprint,
),
)
}
}
is UserFingerprintResult.Error -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
}
/**
@ -61,6 +98,7 @@ class LoginWithDeviceViewModel @Inject constructor(
*/
@Parcelize
data class LoginWithDeviceState(
val emailAddress: String,
val viewState: ViewState,
) : Parcelable {
/**
@ -133,4 +171,16 @@ sealed class LoginWithDeviceAction {
* Indicates that the "View all log in options" text has been clicked.
*/
data object ViewAllLogInOptionsClick : LoginWithDeviceAction()
/**
* Models actions for internal use by the view model.
*/
sealed class Internal : LoginWithDeviceAction() {
/**
* A fingerprint phrase for this user has been received.
*/
data class FingerprintPhraseReceived(
val result: UserFingerprintResult,
) : Internal()
}
}

View file

@ -1,10 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.sdk.ClientAuth
import com.bitwarden.sdk.ClientPlatform
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
@ -17,11 +20,61 @@ import org.junit.jupiter.api.Test
class AuthSdkSourceTest {
private val clientAuth = mockk<ClientAuth>()
private val clientPlatform = mockk<ClientPlatform>()
private val authSkdSource: AuthSdkSource = AuthSdkSourceImpl(
clientAuth = clientAuth,
clientPlatform = clientPlatform,
)
@Test
fun `getNewAuthRequest should call SDK and return a Result with correct data`() = runBlocking {
val email = "test@gmail.com"
val expectedResult = mockk<AuthRequestResponse>()
coEvery {
clientAuth.newAuthRequest(email)
} returns expectedResult
val result = authSkdSource.getNewAuthRequest(email)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientAuth.newAuthRequest(email)
}
}
@Test
fun `getUserFingerprint should call SDK and return a Result with correct data`() = runBlocking {
val email = "email@gmail.com"
val publicKey = "publicKey"
val expectedResult = "fingerprint"
coEvery {
clientPlatform.fingerprint(
req = FingerprintRequest(
fingerprintMaterial = email,
publicKey = publicKey,
),
)
} returns expectedResult
val result = authSkdSource.getUserFingerprint(email, publicKey)
assertEquals(
expectedResult.asSuccess(),
result,
)
coVerify {
clientPlatform.fingerprint(
req = FingerprintRequest(
fingerprintMaterial = email,
publicKey = publicKey,
),
)
}
}
@Test
fun `hashPassword should call SDK and return a Result with the correct data`() = runBlocking {
val email = "email"

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository
import app.cash.turbine.test
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
@ -44,6 +45,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@ -1374,6 +1376,68 @@ class AuthRepositoryTest {
assertEquals(expected, result)
}
@Test
fun `getUserFingerprint should return failure when source returns failure`() = runTest {
coEvery {
authSdkSource.getNewAuthRequest(EMAIL)
} returns Result.success(
mockk<AuthRequestResponse> {
every { publicKey } returns PUBLIC_KEY
},
)
coEvery {
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
} returns Result.failure(Throwable())
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.getFingerprintPhrase(EMAIL)
coVerify(exactly = 1) {
authSdkSource.getNewAuthRequest(EMAIL)
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
}
assertEquals(UserFingerprintResult.Error, result)
}
@Test
fun `getUserFingerprint should return success when source returns success`() = runTest {
val fingerprint = "fingerprint"
coEvery {
authSdkSource.getNewAuthRequest(EMAIL)
} returns Result.success(
AuthRequestResponse(
fingerprint = fingerprint,
publicKey = PUBLIC_KEY,
privateKey = "key",
accessCode = "accessCode",
),
)
coEvery {
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
} returns Result.success(fingerprint)
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.getFingerprintPhrase(EMAIL)
coVerify(exactly = 1) {
authSdkSource.getNewAuthRequest(EMAIL)
authSdkSource.getUserFingerprint(
email = EMAIL,
publicKey = PUBLIC_KEY,
)
}
assertEquals(UserFingerprintResult.Success(fingerprint), result)
}
@Test
fun `getIsKnownDevice should return failure when service returns failure`() = runTest {
coEvery {

View file

@ -297,15 +297,16 @@ class LoginScreenTest : BaseComposeTest() {
@Test
fun `NavigateToLoginWithDevice should call onNavigateToLoginWithDevice`() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToLoginWithDevice)
mutableEventFlow.tryEmit(LoginEvent.NavigateToLoginWithDevice(EMAIL))
assertTrue(onNavigateToLoginWithDeviceCalled)
}
}
private const val EMAIL = "active@bitwarden.com"
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
email = EMAIL,
avatarColorHex = "#aa00aa",
environmentLabel = "bitwarden.com",
isActive = true,
@ -315,7 +316,7 @@ private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
private val DEFAULT_STATE =
LoginState(
emailAddress = "",
emailAddress = EMAIL,
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",

View file

@ -40,14 +40,14 @@ import org.junit.jupiter.api.Test
class LoginViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle().also {
it["email_address"] = "test@gmail.com"
it["email_address"] = EMAIL
}
private val mutableCaptchaTokenResultFlow =
bufferedMutableSharedFlow<CaptchaCallbackTokenResult>()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository: AuthRepository = mockk(relaxed = true) {
coEvery {
getIsKnownDevice("test@gmail.com")
getIsKnownDevice(EMAIL)
} returns KnownDeviceResult.Success(false)
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { userStateFlow } returns mutableUserStateFlow
@ -160,7 +160,7 @@ class LoginViewModelTest : BaseViewModelTest() {
shouldShowLoginWithDevice = true,
)
coEvery {
authRepository.getIsKnownDevice("test@gmail.com")
authRepository.getIsKnownDevice(EMAIL)
} returns KnownDeviceResult.Success(true)
val viewModel = createViewModel()
@ -172,7 +172,7 @@ class LoginViewModelTest : BaseViewModelTest() {
@Test
fun `should have default state when isKnownDevice returns error`() = runTest {
coEvery {
authRepository.getIsKnownDevice("test@gmail.com")
authRepository.getIsKnownDevice(EMAIL)
} returns KnownDeviceResult.Error
val viewModel = createViewModel()
@ -246,7 +246,7 @@ class LoginViewModelTest : BaseViewModelTest() {
fun `LoginButtonClick login returns error should update errorDialogState`() = runTest {
coEvery {
authRepository.login(
email = "test@gmail.com",
email = EMAIL,
password = "",
captchaToken = null,
)
@ -275,7 +275,7 @@ class LoginViewModelTest : BaseViewModelTest() {
)
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "", captchaToken = null)
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@ -283,7 +283,7 @@ class LoginViewModelTest : BaseViewModelTest() {
fun `LoginButtonClick login returns success should update loadingDialogState`() = runTest {
coEvery {
authRepository.login(
email = "test@gmail.com",
email = EMAIL,
password = "",
captchaToken = null,
)
@ -306,7 +306,7 @@ class LoginViewModelTest : BaseViewModelTest() {
)
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "", captchaToken = null)
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@ -319,7 +319,7 @@ class LoginViewModelTest : BaseViewModelTest() {
} returns mockkUri
coEvery {
authRepository.login(
email = "test@gmail.com",
email = EMAIL,
password = "",
captchaToken = null,
)
@ -331,7 +331,7 @@ class LoginViewModelTest : BaseViewModelTest() {
assertEquals(LoginEvent.NavigateToCaptcha(uri = mockkUri), awaitItem())
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "", captchaToken = null)
authRepository.login(email = EMAIL, password = "", captchaToken = null)
}
}
@ -342,7 +342,7 @@ class LoginViewModelTest : BaseViewModelTest() {
viewModel.actionChannel.trySend(LoginAction.MasterPasswordHintClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
LoginEvent.NavigateToMasterPasswordHint("test@gmail.com"),
LoginEvent.NavigateToMasterPasswordHint(EMAIL),
awaitItem(),
)
}
@ -355,7 +355,7 @@ class LoginViewModelTest : BaseViewModelTest() {
viewModel.actionChannel.trySend(LoginAction.LoginWithDeviceButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
LoginEvent.NavigateToLoginWithDevice,
LoginEvent.NavigateToLoginWithDevice(EMAIL),
awaitItem(),
)
}
@ -435,7 +435,7 @@ class LoginViewModelTest : BaseViewModelTest() {
fun `captchaTokenFlow success update should trigger a login`() = runTest {
coEvery {
authRepository.login(
email = "test@gmail.com",
email = EMAIL,
password = "",
captchaToken = "token",
)
@ -443,7 +443,7 @@ class LoginViewModelTest : BaseViewModelTest() {
createViewModel()
mutableCaptchaTokenResultFlow.tryEmit(CaptchaCallbackTokenResult.Success("token"))
coVerify {
authRepository.login(email = "test@gmail.com", password = "", captchaToken = "token")
authRepository.login(email = EMAIL, password = "", captchaToken = "token")
}
}
@ -456,8 +456,9 @@ class LoginViewModelTest : BaseViewModelTest() {
)
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = LoginState(
emailAddress = "test@gmail.com",
emailAddress = EMAIL,
passwordInput = "",
isLoginButtonEnabled = false,
environmentLabel = Environment.Us.label,

View file

@ -105,7 +105,9 @@ class LoginWithDeviceScreenTest : BaseComposeTest() {
}
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = LoginWithDeviceState(
emailAddress = EMAIL,
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
),

View file

@ -2,14 +2,25 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
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.Test
class LoginWithDeviceViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle()
private val authRepository = mockk<AuthRepository> {
coEvery {
getFingerprintPhrase(EMAIL)
} returns UserFingerprintResult.Success("initialFingerprint")
}
@Test
fun `initial state should be correct`() = runTest {
@ -17,6 +28,26 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
coVerify { authRepository.getFingerprintPhrase(EMAIL) }
}
@Test
fun `initial state should be correct when set`() = runTest {
val newEmail = "newEmail@gmail.com"
coEvery {
authRepository.getFingerprintPhrase(newEmail)
} returns UserFingerprintResult.Success("initialFingerprint")
val state = LoginWithDeviceState(
emailAddress = newEmail,
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "initialFingerprint",
),
)
val viewModel = createViewModel(state)
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
}
coVerify { authRepository.getFingerprintPhrase(newEmail) }
}
@Test
@ -57,15 +88,59 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
}
private fun createViewModel(): LoginWithDeviceViewModel =
@Test
fun `on fingerprint result success received should show content`() = runTest {
val newFingerprint = "newFingerprint"
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.actionChannel.trySend(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = UserFingerprintResult.Success(newFingerprint),
),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = newFingerprint,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on fingerprint result failure received should show error`() = runTest {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.actionChannel.trySend(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
result = UserFingerprintResult.Error,
),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = LoginWithDeviceState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
state: LoginWithDeviceState = DEFAULT_STATE,
): LoginWithDeviceViewModel =
LoginWithDeviceViewModel(
savedStateHandle = savedStateHandle,
authRepository = authRepository,
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
companion object {
private const val EMAIL = "test@gmail.com"
private val DEFAULT_STATE = LoginWithDeviceState(
emailAddress = EMAIL,
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
fingerprintPhrase = "initialFingerprint",
),
)
}