From a7e393e325a47e61dd441000ec20c2c5f1e9276e Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Fri, 26 Jan 2024 14:17:21 -0700 Subject: [PATCH] Refactor logic for auth requests & decrypt all fingerprints (#800) --- .../data/auth/repository/AuthRepository.kt | 6 - .../auth/repository/AuthRepositoryImpl.kt | 114 ++++++++++-------- .../data/auth/repository/model/AuthRequest.kt | 2 + .../platform/repository/SettingsRepository.kt | 6 + .../repository/SettingsRepositoryImpl.kt | 14 +++ .../vault/datasource/sdk/VaultSdkSource.kt | 5 + .../datasource/sdk/VaultSdkSourceImpl.kt | 9 ++ .../LoginWithDeviceViewModel.kt | 35 +----- .../AccountSecurityViewModel.kt | 42 ++++++- .../PendingRequestsViewModel.kt | 2 +- .../auth/repository/AuthRepositoryTest.kt | 111 +++++++---------- .../repository/SettingsRepositoryTest.kt | 78 ++++++++++-- .../datasource/sdk/VaultSdkSourceTest.kt | 25 ++++ .../LoginWithDeviceViewModelTest.kt | 48 +++++--- .../AccountSecurityViewModelTest.kt | 58 ++++++++- .../PendingRequestsViewModelTest.kt | 6 +- 16 files changed, 367 insertions(+), 194 deletions(-) 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 8003d02067..0da630839f 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 @@ -16,7 +16,6 @@ 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.ResendEmailResult 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 @@ -184,11 +183,6 @@ 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 055d06b942..d839dcc820 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 @@ -582,42 +582,17 @@ class AuthRepositoryImpl( authSdkSource .getNewAuthRequest(email) .flatMap { authRequest -> - newAuthRequestService.createAuthRequest( - email = email, - publicKey = authRequest.publicKey, - deviceId = authDiskSource.uniqueAppId, - accessCode = authRequest.accessCode, - fingerprint = authRequest.fingerprint, - ) - } - .fold( - onFailure = { AuthRequestResult.Error }, - onSuccess = { request -> - AuthRequestResult.Success( - authRequest = AuthRequest( - id = request.id, - publicKey = request.publicKey, - platform = request.platform, - ipAddress = request.ipAddress, - key = request.key, - masterPasswordHash = request.masterPasswordHash, - creationDate = request.creationDate, - responseDate = request.responseDate, - requestApproved = request.requestApproved ?: false, - originUrl = request.originUrl, - ), + newAuthRequestService + .createAuthRequest( + email = email, + publicKey = authRequest.publicKey, + deviceId = authDiskSource.uniqueAppId, + accessCode = authRequest.accessCode, + fingerprint = authRequest.fingerprint, ) - }, - ) - - override suspend fun getAuthRequests(): AuthRequestsResult = - authRequestsService.getAuthRequests() - .fold( - onFailure = { AuthRequestsResult.Error }, - onSuccess = { response -> - AuthRequestsResult.Success( - authRequests = response.authRequests.map { request -> - AuthRequest( + .map { request -> + AuthRequestResult.Success( + authRequest = AuthRequest( id = request.id, publicKey = request.publicKey, platform = request.platform, @@ -628,29 +603,45 @@ class AuthRepositoryImpl( responseDate = request.responseDate, requestApproved = request.requestApproved ?: false, originUrl = request.originUrl, - ) + fingerprint = authRequest.fingerprint, + ), + ) + } + } + .fold( + onFailure = { AuthRequestResult.Error }, + onSuccess = { it }, + ) + + override suspend fun getAuthRequests(): AuthRequestsResult = + authRequestsService + .getAuthRequests() + .fold( + onFailure = { AuthRequestsResult.Error }, + onSuccess = { response -> + AuthRequestsResult.Success( + authRequests = response.authRequests.mapNotNull { request -> + when (val result = getFingerprintPhrase(request.publicKey)) { + is UserFingerprintResult.Error -> null + is UserFingerprintResult.Success -> AuthRequest( + id = request.id, + publicKey = request.publicKey, + platform = request.platform, + ipAddress = request.ipAddress, + key = request.key, + masterPasswordHash = request.masterPasswordHash, + creationDate = request.creationDate, + responseDate = request.responseDate, + requestApproved = request.requestApproved ?: false, + originUrl = request.originUrl, + fingerprint = result.fingerprint, + ) + } }, ) }, ) - 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( @@ -690,6 +681,23 @@ class AuthRepositoryImpl( }, ) + private suspend fun getFingerprintPhrase( + publicKey: String, + ): UserFingerprintResult { + val profile = authDiskSource.userState?.activeAccount?.profile + ?: return UserFingerprintResult.Error + + return authSdkSource + .getUserFingerprint( + email = profile.email, + publicKey = publicKey, + ) + .fold( + onFailure = { UserFingerprintResult.Error }, + onSuccess = { UserFingerprintResult.Success(it) }, + ) + } + /** * Get the remembered two-factor token associated with the user's email, if applicable. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt index 4c19fb4ee4..ad0a5bb80c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt @@ -15,6 +15,7 @@ import java.time.ZonedDateTime * @param responseDate The date & time on which this request was responded to. * @param requestApproved Whether this request was approved. * @param originUrl The origin URL of this auth request. + * @param fingerprint The fingerprint of this auth request. */ data class AuthRequest( val id: String, @@ -27,4 +28,5 @@ data class AuthRequest( val responseDate: ZonedDateTime?, val requestApproved: Boolean, val originUrl: String, + val fingerprint: String, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index aa13896d29..58a0ad129a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.platform.repository +import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction @@ -113,6 +114,11 @@ interface SettingsRepository { */ fun disableAutofill() + /** + * Gets the unique fingerprint phrase for the current user. + */ + suspend fun getUserFingerprint(): UserFingerprintResult + /** * Sets default values for various settings for the given [userId] if necessary. This is * typically used when logging into a new account. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index afe7815cd9..92c8c12483 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository import android.view.autofill.AutofillManager import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager @@ -252,6 +253,19 @@ class SettingsRepositoryImpl( mutableIsAutofillEnabledStateFlow.value = false } + @Suppress("ReturnCount") + override suspend fun getUserFingerprint(): UserFingerprintResult { + val userId = activeUserId + ?: return UserFingerprintResult.Error + + return vaultSdkSource + .getUserFingerprint(userId) + .fold( + onFailure = { UserFingerprintResult.Error }, + onSuccess = { UserFingerprintResult.Success(it) }, + ) + } + override fun setDefaultsIfNecessary(userId: String) { // Set Vault Settings defaults if (!isVaultTimeoutActionSet(userId = userId)) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index 34060fc359..47528b73c3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -68,6 +68,11 @@ interface VaultSdkSource { */ suspend fun getUserEncryptionKey(userId: String): Result + /** + * Gets the user's fingerprint. + */ + suspend fun getUserFingerprint(userId: String): Result + /** * Attempts to initialize cryptography functionality for an individual user with the given * [userId] for the Bitwarden SDK with a given [InitUserCryptoRequest]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 9fe6971643..f4b74cb6b2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -65,6 +65,15 @@ class VaultSdkSourceImpl( .getUserEncryptionKey() } + override suspend fun getUserFingerprint( + userId: String, + ): Result = + runCatching { + getClient(userId = userId) + .platform() + .userFingerprint(userId) + } + override suspend fun initializeCrypto( userId: String, request: InitUserCryptoRequest, 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 1849dd6308..fd1798429f 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 @@ -6,7 +6,6 @@ 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.AuthRequestResult -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 @@ -34,14 +33,6 @@ class LoginWithDeviceViewModel @Inject constructor( ) { init { sendNewAuthRequest() - - viewModelScope.launch { - trySendAction( - LoginWithDeviceAction.Internal.FingerprintPhraseReceived( - result = authRepository.getFingerprintPhrase(state.emailAddress), - ), - ) - } } override fun handleAction(action: LoginWithDeviceAction) { @@ -53,10 +44,6 @@ class LoginWithDeviceViewModel @Inject constructor( is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> { handleNewAuthRequestResultReceived(action) } - - is LoginWithDeviceAction.Internal.FingerprintPhraseReceived -> { - handleFingerprintPhraseReceived(action) - } } } @@ -75,27 +62,20 @@ class LoginWithDeviceViewModel @Inject constructor( private fun handleNewAuthRequestResultReceived( action: LoginWithDeviceAction.Internal.NewAuthRequestResultReceive, - ) { - if (action.result is AuthRequestResult.Error) { - // TODO BIT-1563 handle error - } - } - - private fun handleFingerprintPhraseReceived( - action: LoginWithDeviceAction.Internal.FingerprintPhraseReceived, ) { when (action.result) { - is UserFingerprintResult.Success -> { + is AuthRequestResult.Success -> { mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( - fingerprintPhrase = action.result.fingerprint, + fingerprintPhrase = action.result.authRequest.fingerprint, ), ) } } - is UserFingerprintResult.Error -> { + is AuthRequestResult.Error -> { + // TODO BIT-1563 display error dialog mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Error( @@ -209,12 +189,5 @@ sealed class LoginWithDeviceAction { data class NewAuthRequestResultReceive( val result: AuthRequestResult, ) : Internal() - - /** - * A fingerprint phrase for this user has been received. - */ - data class FingerprintPhraseReceived( - val result: UserFingerprintResult, - ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index bed9c6728d..bc35e73604 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -5,6 +5,7 @@ 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.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout @@ -18,6 +19,7 @@ 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 javax.inject.Inject @@ -38,7 +40,7 @@ class AccountSecurityViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: AccountSecurityState( dialog = null, - fingerprintPhrase = "fingerprint-placeholder".asText(), + fingerprintPhrase = "".asText(), // This will be filled in dynamically isApproveLoginRequestsEnabled = settingsRepository.isApprovePasswordlessLoginsEnabled, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled, @@ -59,6 +61,14 @@ class AccountSecurityViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + + viewModelScope.launch { + trySendAction( + AccountSecurityAction.Internal.FingerprintResultReceive( + fingerprintResult = settingsRepository.getUserFingerprint(), + ), + ) + } } override fun handleAction(action: AccountSecurityAction): Unit = when (action) { @@ -92,6 +102,10 @@ class AccountSecurityViewModel @Inject constructor( is AccountSecurityAction.PushNotificationConfirm -> { handlePushNotificationConfirm() } + + is AccountSecurityAction.Internal.FingerprintResultReceive -> { + handleFingerprintResultReceived(action) + } } private fun handleAccountFingerprintPhraseClick() { @@ -239,6 +253,20 @@ class AccountSecurityViewModel @Inject constructor( } } } + + private fun handleFingerprintResultReceived( + action: AccountSecurityAction.Internal.FingerprintResultReceive, + ) { + mutableStateFlow.update { + it.copy( + fingerprintPhrase = when (val result = action.fingerprintResult) { + is UserFingerprintResult.Success -> result.fingerprint.asText() + // This should never fail for an unlocked account. + is UserFingerprintResult.Error -> "".asText() + }, + ) + } + } } /** @@ -474,4 +502,16 @@ sealed class AccountSecurityAction { override val isUnlockWithPinEnabled: Boolean get() = true } } + + /** + * Models actions that can be sent by the view model itself. + */ + sealed class Internal : AccountSecurityAction() { + /** + * A fingerprint has been received. + */ + data class FingerprintResultReceive( + val fingerprintResult: UserFingerprintResult, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt index 94ace4007d..b2d692b169 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt @@ -77,7 +77,7 @@ class PendingRequestsViewModel @Inject constructor( PendingRequestsState.ViewState.Content( requests = result.authRequests.map { authRequest -> PendingRequestsState.ViewState.Content.PendingLoginRequest( - fingerprintPhrase = authRequest.publicKey, + fingerprintPhrase = authRequest.fingerprint, platform = authRequest.platform, timestamp = dateTimeFormatter.format( authRequest.creationDate, 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 2cedb6af26..c4f99bce7a 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 @@ -50,7 +50,6 @@ 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.ResendEmailResult 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 @@ -2135,6 +2134,7 @@ class AuthRepositoryTest { responseDate = null, requestApproved = true, originUrl = "www.bitwarden.com", + fingerprint = fingerprint, ), ) coEvery { @@ -2179,11 +2179,12 @@ class AuthRepositoryTest { @Test fun `getAuthRequests should return success when service returns success`() = runTest { + val fingerprint = "fingerprint" val responseJson = AuthRequestsResponseJson( authRequests = listOf( AuthRequestsResponseJson.AuthRequest( id = "1", - publicKey = "2", + publicKey = PUBLIC_KEY, platform = "Android", ipAddress = "192.168.0.1", key = "public", @@ -2199,7 +2200,46 @@ class AuthRepositoryTest { authRequests = listOf( AuthRequest( id = "1", - publicKey = "2", + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = fingerprint, + ), + ), + ) + coEvery { + authSdkSource.getUserFingerprint( + email = EMAIL, + publicKey = PUBLIC_KEY, + ) + } returns Result.success(fingerprint) + coEvery { + authRequestsService.getAuthRequests() + } returns responseJson.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val result = repository.getAuthRequests() + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + assertEquals(expected, result) + } + + @Test + fun `getAuthRequests should return empty list when user profile is null`() = runTest { + val responseJson = AuthRequestsResponseJson( + authRequests = listOf( + AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = PUBLIC_KEY, platform = "Android", ipAddress = "192.168.0.1", key = "public", @@ -2211,6 +2251,7 @@ class AuthRepositoryTest { ), ), ) + val expected = AuthRequestsResult.Success(emptyList()) coEvery { authRequestsService.getAuthRequests() } returns responseJson.asSuccess() @@ -2223,68 +2264,6 @@ 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 { @@ -2462,7 +2441,7 @@ class AuthRepositoryTest { private val ACCOUNT_1 = AccountJson( profile = AccountJson.Profile( userId = USER_ID_1, - email = "test@bitwarden.com", + email = EMAIL, isEmailVerified = true, name = "Bitwarden Tester", hasPremium = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index e39bf6abc7..3a86a75e75 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import com.bitwarden.core.DerivePinKeyResponse import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager @@ -555,6 +556,56 @@ class SettingsRepositoryTest { verify { autofillManager.disableAutofillServices() } } + @Test + fun `getUserFingerprint should return failure with no active user`() = runTest { + fakeAuthDiskSource.userState = null + + val result = settingsRepository.getUserFingerprint() + + assertEquals(UserFingerprintResult.Error, result) + } + + @Test + fun `getUserFingerprint should return failure with active user when source returns failure`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.getUserFingerprint( + userId = MOCK_USER_STATE.activeUserId, + ) + } returns Result.failure(Throwable()) + + val result = settingsRepository.getUserFingerprint() + + coVerify(exactly = 1) { + vaultSdkSource.getUserFingerprint( + userId = MOCK_USER_STATE.activeUserId, + ) + } + assertEquals(UserFingerprintResult.Error, result) + } + + @Test + fun `getUserFingerprint should return success with active user when source returns success`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val fingerprint = "fingerprint" + coEvery { + vaultSdkSource.getUserFingerprint( + userId = MOCK_USER_STATE.activeUserId, + ) + } returns Result.success(fingerprint) + + val result = settingsRepository.getUserFingerprint() + + coVerify(exactly = 1) { + vaultSdkSource.getUserFingerprint( + userId = MOCK_USER_STATE.activeUserId, + ) + } + assertEquals(UserFingerprintResult.Success(fingerprint), result) + } + @Test fun `getPullToRefreshEnabledFlow should react to changes in SettingsDiskSource`() = runTest { val userId = "userId" @@ -728,26 +779,27 @@ class SettingsRepositoryTest { @Suppress("MaxLineLength") @Test - fun `isScreenCaptureAllowed property should update SettingsDiskSource and emit changes`() = runTest { - val userId = "userId" - fakeAuthDiskSource.userState = MOCK_USER_STATE + fun `isScreenCaptureAllowed property should update SettingsDiskSource and emit changes`() = + runTest { + val userId = "userId" + fakeAuthDiskSource.userState = MOCK_USER_STATE - fakeSettingsDiskSource.storeScreenCaptureAllowed(userId, false) + fakeSettingsDiskSource.storeScreenCaptureAllowed(userId, false) - settingsRepository.isScreenCaptureAllowedStateFlow.test { - assertFalse(awaitItem()) + settingsRepository.isScreenCaptureAllowedStateFlow.test { + assertFalse(awaitItem()) - settingsRepository.isScreenCaptureAllowed = true - assertTrue(awaitItem()) + settingsRepository.isScreenCaptureAllowed = true + assertTrue(awaitItem()) - assertEquals(true, fakeSettingsDiskSource.getScreenCaptureAllowed(userId)) + assertEquals(true, fakeSettingsDiskSource.getScreenCaptureAllowed(userId)) - settingsRepository.isScreenCaptureAllowed = false - assertFalse(awaitItem()) + settingsRepository.isScreenCaptureAllowed = false + assertFalse(awaitItem()) - assertEquals(false, fakeSettingsDiskSource.getScreenCaptureAllowed(userId)) + assertEquals(false, fakeSettingsDiskSource.getScreenCaptureAllowed(userId)) + } } - } } private val MOCK_USER_STATE = diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index dbc4593478..48191f77a6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -22,6 +22,7 @@ import com.bitwarden.sdk.BitwardenException import com.bitwarden.sdk.Client import com.bitwarden.sdk.ClientCrypto import com.bitwarden.sdk.ClientPasswordHistory +import com.bitwarden.sdk.ClientPlatform import com.bitwarden.sdk.ClientVault import com.x8bit.bitwarden.data.platform.manager.SdkClientManager import com.x8bit.bitwarden.data.platform.util.asFailure @@ -42,12 +43,14 @@ import org.junit.jupiter.api.Test @Suppress("LargeClass") class VaultSdkSourceTest { private val clientCrypto = mockk() + private val clientPlatform = mockk() private val clientPasswordHistory = mockk() private val clientVault = mockk() { every { passwordHistory() } returns clientPasswordHistory } private val client = mockk() { every { vault() } returns clientVault + every { platform() } returns clientPlatform every { crypto() } returns clientCrypto } private val sdkClientManager = mockk { @@ -132,6 +135,28 @@ class VaultSdkSourceTest { verify { sdkClientManager.getOrCreateClient(userId = userId) } } + @Test + fun `getUserFingerprint should call SDK and return a Result with correct data`() = runBlocking { + val userId = "userId" + val expectedResult = "fingerprint" + coEvery { + clientPlatform.userFingerprint( + fingerprintMaterial = userId, + ) + } returns expectedResult + + val result = vaultSdkSource.getUserFingerprint(userId) + assertEquals( + expectedResult.asSuccess(), + result, + ) + coVerify { + clientPlatform.userFingerprint( + fingerprintMaterial = userId, + ) + } + } + @Test fun `initializeUserCrypto with sdk success should return InitializeCryptoResult Success`() = runBlocking { 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 73f1b42b6b..75cc260dee 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 @@ -4,26 +4,25 @@ 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.AuthRequest import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult -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.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import java.time.ZonedDateTime class LoginWithDeviceViewModelTest : BaseViewModelTest() { private val authRepository = mockk { - coEvery { - getFingerprintPhrase(EMAIL) - } returns UserFingerprintResult.Success("initialFingerprint") coEvery { createAuthRequest(EMAIL) - } returns mockk() + } returns AuthRequestResult.Success(AUTH_REQUEST) } @Test @@ -33,7 +32,6 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { assertEquals(DEFAULT_STATE, awaitItem()) } coVerify { authRepository.createAuthRequest(EMAIL) } - coVerify { authRepository.getFingerprintPhrase(EMAIL) } } @Test @@ -42,14 +40,11 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { coEvery { authRepository.createAuthRequest(newEmail) - } returns mockk() - coEvery { - authRepository.getFingerprintPhrase(newEmail) - } returns UserFingerprintResult.Success("initialFingerprint") + } returns AuthRequestResult.Success(AUTH_REQUEST) val state = LoginWithDeviceState( emailAddress = newEmail, viewState = LoginWithDeviceState.ViewState.Content( - fingerprintPhrase = "initialFingerprint", + fingerprintPhrase = FINGERPRINT, ), ) val viewModel = createViewModel(state) @@ -58,7 +53,6 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } coVerify { authRepository.createAuthRequest(newEmail) - authRepository.getFingerprintPhrase(newEmail) } } @@ -101,13 +95,17 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } @Test - fun `on fingerprint result success received should show content`() = runTest { + fun `on auth request 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), + LoginWithDeviceAction.Internal.NewAuthRequestResultReceive( + result = AuthRequestResult.Success( + authRequest = mockk { + every { fingerprint } returns newFingerprint + }, + ), ), ) assertEquals( @@ -125,8 +123,8 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) viewModel.actionChannel.trySend( - LoginWithDeviceAction.Internal.FingerprintPhraseReceived( - result = UserFingerprintResult.Error, + LoginWithDeviceAction.Internal.NewAuthRequestResultReceive( + result = AuthRequestResult.Error, ), ) assertEquals( @@ -149,11 +147,25 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { companion object { private const val EMAIL = "test@gmail.com" + private const val FINGERPRINT = "fingerprint" private val DEFAULT_STATE = LoginWithDeviceState( emailAddress = EMAIL, viewState = LoginWithDeviceState.ViewState.Content( - fingerprintPhrase = "initialFingerprint", + fingerprintPhrase = FINGERPRINT, ), ) + private val AUTH_REQUEST = AuthRequest( + id = "1", + publicKey = "2", + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = FINGERPRINT, + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 1e5a4a8bf1..98759959a9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment @@ -12,6 +13,8 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito import com.x8bit.bitwarden.data.vault.repository.VaultRepository 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.every import io.mockk.just import io.mockk.mockk @@ -29,8 +32,13 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when saved state is set`() { - val viewModel = createViewModel(initialState = DEFAULT_STATE) + val settingsRepository = getMockSettingsRepository() + val viewModel = createViewModel( + initialState = DEFAULT_STATE, + settingsRepository = settingsRepository, + ) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + coVerify { settingsRepository.getUserFingerprint() } } @Test @@ -40,6 +48,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { every { vaultTimeout } returns VaultTimeout.ThirtyMinutes every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK every { isApprovePasswordlessLoginsEnabled } returns false + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel( initialState = null, @@ -49,6 +58,33 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { DEFAULT_STATE.copy(isUnlockWithPinEnabled = true), viewModel.stateFlow.value, ) + coVerify { settingsRepository.getUserFingerprint() } + } + + @Test + fun `on FingerprintResultReceive should update the fingerprint phrase`() = runTest { + val fingerprint = "fingerprint" + val viewModel = createViewModel() + // Set fingerprint phrase to value received + viewModel.trySendAction( + AccountSecurityAction.Internal.FingerprintResultReceive( + UserFingerprintResult.Success(fingerprint), + ), + ) + assertEquals( + DEFAULT_STATE.copy(fingerprintPhrase = fingerprint.asText()), + viewModel.stateFlow.value, + ) + // Clear fingerprint phrase + viewModel.trySendAction( + AccountSecurityAction.Internal.FingerprintResultReceive( + UserFingerprintResult.Error, + ), + ) + assertEquals( + DEFAULT_STATE.copy(fingerprintPhrase = "".asText()), + viewModel.stateFlow.value, + ) } @Test @@ -151,6 +187,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { fun `on VaultTimeoutTypeSelect should update the selection()`() = runTest { val settingsRepository = mockk() { every { vaultTimeout = any() } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel(settingsRepository = settingsRepository) viewModel.trySendAction( @@ -169,6 +206,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { fun `on CustomVaultTimeoutSelect should update the selection()`() = runTest { val settingsRepository = mockk() { every { vaultTimeout = any() } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel(settingsRepository = settingsRepository) viewModel.trySendAction( @@ -191,6 +229,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { fun `on VaultTimeoutActionSelect should update vault timeout action`() = runTest { val settingsRepository = mockk() { every { vaultTimeoutAction = any() } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel(settingsRepository = settingsRepository) viewModel.trySendAction( @@ -257,6 +296,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { ) val settingsRepository: SettingsRepository = mockk() { every { clearUnlockPin() } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel( initialState = initialState, @@ -296,6 +336,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { ) val settingsRepository: SettingsRepository = mockk() { every { storeUnlockPin(any(), any()) } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel( initialState = initialState, @@ -353,6 +394,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { runTest { val settingsRepository = mockk { every { isApprovePasswordlessLoginsEnabled = true } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel( settingsRepository = settingsRepository, @@ -387,6 +429,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { runTest { val settingsRepository = mockk { every { isApprovePasswordlessLoginsEnabled = false } just runs + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } val viewModel = createViewModel( settingsRepository = settingsRepository, @@ -416,13 +459,21 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } } + /** + * Returns a [mockk] of the [SettingsRepository] with the call made on init already mocked. + */ + private fun getMockSettingsRepository(): SettingsRepository = + mockk { + coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) + } + @Suppress("LongParameterList") private fun createViewModel( initialState: AccountSecurityState? = DEFAULT_STATE, authRepository: AuthRepository = mockk(relaxed = true), vaultRepository: VaultRepository = mockk(relaxed = true), - settingsRepository: SettingsRepository = mockk(relaxed = true), environmentRepository: EnvironmentRepository = fakeEnvironmentRepository, + settingsRepository: SettingsRepository = getMockSettingsRepository(), savedStateHandle: SavedStateHandle = SavedStateHandle().apply { set("state", initialState) }, @@ -435,9 +486,10 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { ) companion object { + private const val FINGERPRINT = "fingerprint" private val DEFAULT_STATE = AccountSecurityState( dialog = null, - fingerprintPhrase = "fingerprint-placeholder".asText(), + fingerprintPhrase = FINGERPRINT.asText(), isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt index 4ab41f9d24..221824b580 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt @@ -56,7 +56,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { authRequests = listOf( AuthRequest( id = "1", - publicKey = "pantry-overdue-survive-sleep-jab", + publicKey = "publicKey-1", platform = "Android", ipAddress = "192.168.0.1", key = "publicKey", @@ -65,10 +65,11 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { responseDate = null, requestApproved = true, originUrl = "www.bitwarden.com", + fingerprint = "pantry-overdue-survive-sleep-jab", ), AuthRequest( id = "2", - publicKey = "erupt-anew-matchbook-disk-student", + publicKey = "publicKey-2", platform = "iOS", ipAddress = "192.168.0.2", key = "publicKey", @@ -77,6 +78,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { responseDate = null, requestApproved = false, originUrl = "www.bitwarden.com", + fingerprint = "erupt-anew-matchbook-disk-student", ), ), )