diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt index ce5829125..86b6659f7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt @@ -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 + + /** + * Gets the fingerprint phrase for this [email] and [publicKey]. + */ + suspend fun getUserFingerprint( + email: String, + publicKey: String, + ): Result + /** * Creates a hashed password provided the given [email], [password], [kdf], and [purpose]. */ @@ -21,7 +37,7 @@ interface AuthSdkSource { ): Result /** - * Creates a set of encryption key information for registraation pers + * Creates a set of encryption key information for registration. */ suspend fun makeRegisterKeys( email: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt index fc1a501b9..2d518494a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt @@ -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 = runCatching { + clientAuth.newAuthRequest( + email = email, + ) + } + + override suspend fun getUserFingerprint( + email: String, + publicKey: String, + ): Result = runCatching { + clientPlatform.fingerprint( + req = FingerprintRequest( + fingerprintMaterial = email, + publicKey = publicKey, + ), + ) + } + override suspend fun hashPassword( email: String, password: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/di/AuthSdkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/di/AuthSdkModule.kt index 0ff6dafb3..c115ec09e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/di/AuthSdkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/sdk/di/AuthSdkModule.kt @@ -20,5 +20,8 @@ object AuthSdkModule { @Singleton fun provideAuthSdkSource( client: Client, - ): AuthSdkSource = AuthSdkSourceImpl(clientAuth = client.auth()) + ): AuthSdkSource = AuthSdkSourceImpl( + clientAuth = client.auth(), + clientPlatform = client.platform(), + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 54839ea98..8e76a84f9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index aabeb5f22..b0962e35e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserFingerprintResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserFingerprintResult.kt new file mode 100644 index 000000000..a40e5c4c3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserFingerprintResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 7fc87b37d..5b80801b5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index 78ded61a9..675263851 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -45,7 +45,7 @@ fun NavGraphBuilder.loginDestination( onNavigateBack: () -> Unit, onNavigateToMasterPasswordHint: (emailAddress: String) -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, - onNavigateToLoginWithDevice: () -> Unit, + onNavigateToLoginWithDevice: (emailAddress: String) -> Unit, onNavigateToTwoFactorLogin: () -> Unit, ) { composableWithSlideTransitions( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index e61fcfeeb..c86d3bd5e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index aa106fd98..27df06e83 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt index 75ba42f58..97f2d6722 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt @@ -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) } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index 2d44e273b..4818a5157 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -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( 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() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt index 585a865a4..49f9073a4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt @@ -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() + private val clientPlatform = mockk() 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() + 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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 65231302e..98d5100d1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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 { + 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 { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 88a3b6d47..098be1c55 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -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 = "", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 3f63f8432..914b5d4ee 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -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() private val mutableUserStateFlow = MutableStateFlow(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, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt index 12972673c..76b4f4d55 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt @@ -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", ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt index 2d2236ed1..5d2aae517 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt @@ -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 { + 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", ), ) }