From c0f51d049fd5af817e5790191a7794ad11e55624 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 16 Feb 2024 11:29:02 -0600 Subject: [PATCH] Move auth request logic into its own manager class (#1027) --- .../data/auth/manager/AuthRequestManager.kt | 50 + .../auth/manager/AuthRequestManagerImpl.kt | 372 ++++++ .../data/auth/manager/di/AuthManagerModule.kt | 26 + .../model/AuthRequest.kt | 2 +- .../model/AuthRequestResult.kt | 2 +- .../model/AuthRequestUpdatesResult.kt | 2 +- .../model/AuthRequestsResult.kt | 2 +- .../model/AuthRequestsUpdatesResult.kt | 2 +- .../model/CreateAuthRequestResult.kt | 2 +- .../data/auth/repository/AuthRepository.kt | 45 +- .../auth/repository/AuthRepositoryImpl.kt | 361 +----- .../repository/di/AuthRepositoryModule.kt | 12 +- .../LoginWithDeviceViewModel.kt | 2 +- .../loginapproval/LoginApprovalViewModel.kt | 4 +- .../PendingRequestsViewModel.kt | 4 +- .../auth/manager/AuthRequestManagerTest.kt | 1032 ++++++++++++++++ .../auth/repository/AuthRepositoryTest.kt | 1046 +---------------- .../LoginWithDeviceViewModelTest.kt | 4 +- .../LoginApprovalViewModelTest.kt | 6 +- .../PendingRequestsViewModelTest.kt | 8 +- 20 files changed, 1513 insertions(+), 1471 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerImpl.kt rename app/src/main/java/com/x8bit/bitwarden/data/auth/{repository => manager}/model/AuthRequest.kt (95%) rename app/src/main/java/com/x8bit/bitwarden/data/auth/{repository => manager}/model/AuthRequestResult.kt (87%) rename app/src/main/java/com/x8bit/bitwarden/data/auth/{repository => manager}/model/AuthRequestUpdatesResult.kt (93%) rename app/src/main/java/com/x8bit/bitwarden/data/auth/{repository => manager}/model/AuthRequestsResult.kt (88%) rename app/src/main/java/com/x8bit/bitwarden/data/auth/{repository => manager}/model/AuthRequestsUpdatesResult.kt (88%) rename app/src/main/java/com/x8bit/bitwarden/data/auth/{repository => manager}/model/CreateAuthRequestResult.kt (94%) create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt new file mode 100644 index 000000000..9c514a08e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt @@ -0,0 +1,50 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult +import kotlinx.coroutines.flow.Flow + +/** + * A manager class for handling authentication fo logging in with remote device. + */ +interface AuthRequestManager { + /** + * Creates a new authentication request and then continues to emit updates over time. + */ + fun createAuthRequestWithUpdates(email: String): Flow + + /** + * Get an auth request by its [fingerprint] and emits updates for that request. + */ + fun getAuthRequestByFingerprintFlow(fingerprint: String): Flow + + /** + * Get an auth request by its request ID and emits updates for that request. + */ + fun getAuthRequestByIdFlow(requestId: String): Flow + + /** + * Get all auth request and emits updates over time. + */ + fun getAuthRequestsWithUpdates(): Flow + + /** + * Get a list of the current user's [AuthRequest]s. + */ + suspend fun getAuthRequests(): AuthRequestsResult + + /** + * Approves or declines the request corresponding to this [requestId] based on [publicKey] + * according to [isApproved]. + */ + suspend fun updateAuthRequest( + requestId: String, + masterPasswordHash: String?, + publicKey: String, + isApproved: Boolean, + ): AuthRequestResult +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerImpl.kt new file mode 100644 index 000000000..5f032b2a1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerImpl.kt @@ -0,0 +1,372 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.bitwarden.core.AuthRequestResponse +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService +import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult +import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult +import com.x8bit.bitwarden.data.platform.util.flatMap +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import java.time.Clock +import javax.inject.Singleton +import kotlin.coroutines.coroutineContext + +private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L +private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L +private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000L + +/** + * Default implementation of [AuthRequestManager]. + */ +@Singleton +class AuthRequestManagerImpl( + private val clock: Clock, + private val authRequestsService: AuthRequestsService, + private val newAuthRequestService: NewAuthRequestService, + private val authDiskSource: AuthDiskSource, + private val authSdkSource: AuthSdkSource, + private val vaultSdkSource: VaultSdkSource, +) : AuthRequestManager { + private val activeUserId: String? get() = authDiskSource.userState?.activeUserId + + override fun getAuthRequestsWithUpdates(): Flow = flow { + while (currentCoroutineContext().isActive) { + when (val result = getAuthRequests()) { + AuthRequestsResult.Error -> emit(AuthRequestsUpdatesResult.Error) + + is AuthRequestsResult.Success -> { + emit(AuthRequestsUpdatesResult.Update(authRequests = result.authRequests)) + } + } + delay(timeMillis = PASSWORDLESS_APPROVER_INTERVAL_MILLIS) + } + } + + @Suppress("LongMethod") + override fun createAuthRequestWithUpdates( + email: String, + ): Flow = flow { + val initialResult = createNewAuthRequest(email).getOrNull() ?: run { + emit(CreateAuthRequestResult.Error) + return@flow + } + val authRequestResponse = initialResult.authRequestResponse + var authRequest = initialResult.authRequest + emit(CreateAuthRequestResult.Update(authRequest)) + + var isComplete = false + while (currentCoroutineContext().isActive && !isComplete) { + delay(timeMillis = PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS) + newAuthRequestService + .getAuthRequestUpdate( + requestId = authRequest.id, + accessCode = authRequestResponse.accessCode, + ) + .map { request -> + 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 = authRequest.fingerprint, + ) + } + .fold( + onFailure = { emit(CreateAuthRequestResult.Error) }, + onSuccess = { updateAuthRequest -> + when { + updateAuthRequest.requestApproved -> { + isComplete = true + emit( + CreateAuthRequestResult.Success( + authRequest = updateAuthRequest, + authRequestResponse = authRequestResponse, + ), + ) + } + + !updateAuthRequest.requestApproved && + updateAuthRequest.responseDate != null -> { + isComplete = true + emit(CreateAuthRequestResult.Declined) + } + + updateAuthRequest + .creationDate + .toInstant() + .plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS) + .isBefore(clock.instant()) -> { + isComplete = true + emit(CreateAuthRequestResult.Expired) + } + + else -> { + authRequest = updateAuthRequest + emit(CreateAuthRequestResult.Update(authRequest)) + } + } + }, + ) + } + } + + private fun getAuthRequest( + initialRequest: suspend () -> AuthRequestUpdatesResult, + ): Flow = flow { + val result = initialRequest() + emit(result) + if (result is AuthRequestUpdatesResult.Error) return@flow + var isComplete = false + while (coroutineContext.isActive && !isComplete) { + delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS) + val updateResult = result as AuthRequestUpdatesResult.Update + authRequestsService + .getAuthRequest(result.authRequest.id) + .map { request -> + 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 = updateResult.authRequest.fingerprint, + ) + } + .fold( + onFailure = { emit(AuthRequestUpdatesResult.Error) }, + onSuccess = { updateAuthRequest -> + when { + updateAuthRequest.requestApproved -> { + isComplete = true + emit(AuthRequestUpdatesResult.Approved) + } + + !updateAuthRequest.requestApproved && + updateAuthRequest.responseDate != null -> { + isComplete = true + emit(AuthRequestUpdatesResult.Declined) + } + + updateAuthRequest + .creationDate + .toInstant() + .plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS) + .isBefore(clock.instant()) -> { + isComplete = true + emit(AuthRequestUpdatesResult.Expired) + } + + else -> { + emit(AuthRequestUpdatesResult.Update(updateAuthRequest)) + } + } + }, + ) + } + } + + override fun getAuthRequestByFingerprintFlow( + fingerprint: String, + ): Flow = getAuthRequest { + when (val authRequestsResult = getAuthRequests()) { + AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error + is AuthRequestsResult.Success -> { + authRequestsResult + .authRequests + .firstOrNull { it.fingerprint == fingerprint } + ?.let { AuthRequestUpdatesResult.Update(it) } + ?: AuthRequestUpdatesResult.Error + } + } + } + + override fun getAuthRequestByIdFlow( + requestId: String, + ): Flow = getAuthRequest { + authRequestsService + .getAuthRequest(requestId) + .map { 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, + ) + } + } + .fold( + onFailure = { AuthRequestUpdatesResult.Error }, + onSuccess = { authRequest -> + authRequest + ?.let { AuthRequestUpdatesResult.Update(it) } + ?: AuthRequestUpdatesResult.Error + }, + ) + } + + 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 updateAuthRequest( + requestId: String, + masterPasswordHash: String?, + publicKey: String, + isApproved: Boolean, + ): AuthRequestResult { + val userId = activeUserId ?: return AuthRequestResult.Error + return vaultSdkSource + .getAuthRequestKey( + publicKey = publicKey, + userId = userId, + ) + .flatMap { + authRequestsService.updateAuthRequest( + requestId = requestId, + key = it, + deviceId = authDiskSource.uniqueAppId, + masterPasswordHash = null, + isApproved = isApproved, + ) + } + .map { 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, + fingerprint = "", + ), + ) + } + .fold( + onFailure = { AuthRequestResult.Error }, + onSuccess = { it }, + ) + } + + /** + * Attempts to create a new auth request for the given email and returns a [NewAuthRequestData] + * with the [AuthRequest] and [AuthRequestResponse]. + */ + private suspend fun createNewAuthRequest( + email: String, + ): Result = + authSdkSource + .getNewAuthRequest(email) + .flatMap { authRequestResponse -> + newAuthRequestService + .createAuthRequest( + email = email, + publicKey = authRequestResponse.publicKey, + deviceId = authDiskSource.uniqueAppId, + accessCode = authRequestResponse.accessCode, + fingerprint = authRequestResponse.fingerprint, + ) + .map { request -> + 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 = authRequestResponse.fingerprint, + ) + } + .map { NewAuthRequestData(it, authRequestResponse) } + } + + 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) }, + ) + } +} + +/** + * Wrapper class for the [AuthRequest] and [AuthRequestResponse] data. + */ +private data class NewAuthRequestData( + val authRequest: AuthRequest, + val authRequestResponse: AuthRequestResponse, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt index a01052703..ee23bf336 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt @@ -2,6 +2,11 @@ package com.x8bit.bitwarden.data.auth.manager.di import android.content.Context import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService +import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager +import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager @@ -13,11 +18,13 @@ import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.time.Clock import javax.inject.Singleton /** @@ -42,6 +49,25 @@ object AuthManagerModule { dispatchers = dispatchers, ) + @Provides + @Singleton + fun provideAuthRequestManager( + clock: Clock, + authRequestsService: AuthRequestsService, + newAuthRequestService: NewAuthRequestService, + authSdkSource: AuthSdkSource, + vaultSdkSource: VaultSdkSource, + authDiskSource: AuthDiskSource, + ): AuthRequestManager = + AuthRequestManagerImpl( + clock = clock, + authRequestsService = authRequestsService, + newAuthRequestService = newAuthRequestService, + authSdkSource = authSdkSource, + vaultSdkSource = vaultSdkSource, + authDiskSource = authDiskSource, + ) + @Provides @Singleton fun provideUserLogoutManager( 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/manager/model/AuthRequest.kt similarity index 95% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequest.kt index d6c965d5e..47be395bb 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/manager/model/AuthRequest.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.auth.repository.model +package com.x8bit.bitwarden.data.auth.manager.model import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestResult.kt similarity index 87% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestResult.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestResult.kt index c1638a504..ddc1947a3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestResult.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.auth.repository.model +package com.x8bit.bitwarden.data.auth.manager.model /** * Models result of creating a new login approval request. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestUpdatesResult.kt similarity index 93% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestUpdatesResult.kt index 4ef03bd0b..11dbc535d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestUpdatesResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestUpdatesResult.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.auth.repository.model +package com.x8bit.bitwarden.data.auth.manager.model /** * Models result of an authorization approval request. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestsResult.kt similarity index 88% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsResult.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestsResult.kt index 843d8cd4f..608d18df8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestsResult.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.auth.repository.model +package com.x8bit.bitwarden.data.auth.manager.model /** * Models result of getting the list of login approval requests for the current user. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsUpdatesResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestsUpdatesResult.kt similarity index 88% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsUpdatesResult.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestsUpdatesResult.kt index a6435694a..1a5aa2ea9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsUpdatesResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestsUpdatesResult.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.auth.repository.model +package com.x8bit.bitwarden.data.auth.manager.model /** * Models result of an authorization approval request. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/CreateAuthRequestResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/CreateAuthRequestResult.kt similarity index 94% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/CreateAuthRequestResult.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/CreateAuthRequestResult.kt index c6306f3d0..c424bea1d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/CreateAuthRequestResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/CreateAuthRequestResult.kt @@ -1,4 +1,4 @@ -package com.x8bit.bitwarden.data.auth.repository.model +package com.x8bit.bitwarden.data.auth.manager.model import com.bitwarden.core.AuthRequestResponse 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 044db1989..f2908ab8a 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 @@ -3,14 +3,9 @@ package com.x8bit.bitwarden.data.auth.repository import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel -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.AuthRequestUpdatesResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult -import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult @@ -36,7 +31,7 @@ import kotlinx.coroutines.flow.StateFlow * Provides an API for observing an modifying authentication state. */ @Suppress("TooManyFunctions") -interface AuthRepository : AuthenticatorProvider { +interface AuthRepository : AuthenticatorProvider, AuthRequestManager { /** * Models the current auth state. */ @@ -235,42 +230,6 @@ interface AuthRepository : AuthenticatorProvider { */ fun setSsoCallbackResult(result: SsoCallbackResult) - /** - * Creates a new authentication request and then continues to emit updates over time. - */ - fun createAuthRequestWithUpdates(email: String): Flow - - /** - * Get an auth request by its [fingerprint] and emits updates for that request. - */ - fun getAuthRequestByFingerprintFlow(fingerprint: String): Flow - - /** - * Get an auth request by its request ID and emits updates for that request. - */ - fun getAuthRequestByIdFlow(requestId: String): Flow - - /** - * Get all auth request and emits updates over time. - */ - fun getAuthRequestsWithUpdates(): Flow - - /** - * Get a list of the current user's [AuthRequest]s. - */ - suspend fun getAuthRequests(): AuthRequestsResult - - /** - * Approves or declines the request corresponding to this [requestId] based on [publicKey] - * according to [isApproved]. - */ - suspend fun updateAuthRequest( - requestId: String, - masterPasswordHash: String?, - publicKey: String, - isApproved: Boolean, - ): AuthRequestResult - /** * 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 6c0626b44..b4288dffd 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 @@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.repository import android.os.SystemClock import com.bitwarden.core.AuthRequestMethod -import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.crypto.HashPurpose import com.bitwarden.crypto.Kdf @@ -23,24 +22,17 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordReque import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService -import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService -import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson +import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager -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.AuthRequestUpdatesResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsUpdatesResult import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult -import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult @@ -53,7 +45,6 @@ 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.ResetPasswordResult 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.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType @@ -84,8 +75,6 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -93,20 +82,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.isActive -import java.time.Clock import javax.inject.Singleton -import kotlin.coroutines.coroutineContext - -private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L -private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L -private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000L /** * Default implementation of [AuthRepository]. @@ -114,13 +95,10 @@ private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000 @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @Singleton class AuthRepositoryImpl( - private val clock: Clock, private val accountsService: AccountsService, - private val authRequestsService: AuthRequestsService, private val devicesService: DevicesService, private val haveIBeenPwnedService: HaveIBeenPwnedService, private val identityService: IdentityService, - private val newAuthRequestService: NewAuthRequestService, private val organizationService: OrganizationService, private val authSdkSource: AuthSdkSource, private val vaultSdkSource: VaultSdkSource, @@ -128,12 +106,14 @@ class AuthRepositoryImpl( private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, + private val authRequestManager: AuthRequestManager, private val userLogoutManager: UserLogoutManager, - private val pushManager: PushManager, private val policyManager: PolicyManager, + pushManager: PushManager, dispatcherManager: DispatcherManager, private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() }, -) : AuthRepository { +) : AuthRepository, + AuthRequestManager by authRequestManager { private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false) private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(false) @@ -831,276 +811,6 @@ class AuthRepositoryImpl( mutableSsoCallbackResultFlow.tryEmit(result) } - override fun getAuthRequestsWithUpdates(): Flow = flow { - while (currentCoroutineContext().isActive) { - when (val result = getAuthRequests()) { - AuthRequestsResult.Error -> emit(AuthRequestsUpdatesResult.Error) - - is AuthRequestsResult.Success -> { - emit(AuthRequestsUpdatesResult.Update(authRequests = result.authRequests)) - } - } - delay(timeMillis = PASSWORDLESS_APPROVER_INTERVAL_MILLIS) - } - } - - @Suppress("LongMethod") - override fun createAuthRequestWithUpdates( - email: String, - ): Flow = flow { - val initialResult = createNewAuthRequest(email) - .getOrNull() - ?: run { - emit(CreateAuthRequestResult.Error) - return@flow - } - val authRequestResponse = initialResult.authRequestResponse - var authRequest = initialResult.authRequest - emit(CreateAuthRequestResult.Update(authRequest)) - - var isComplete = false - while (currentCoroutineContext().isActive && !isComplete) { - delay(timeMillis = PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS) - newAuthRequestService - .getAuthRequestUpdate( - requestId = authRequest.id, - accessCode = authRequestResponse.accessCode, - ) - .map { request -> - 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 = authRequest.fingerprint, - ) - } - .fold( - onFailure = { emit(CreateAuthRequestResult.Error) }, - onSuccess = { updateAuthRequest -> - when { - updateAuthRequest.requestApproved -> { - isComplete = true - emit( - CreateAuthRequestResult.Success( - authRequest = updateAuthRequest, - authRequestResponse = authRequestResponse, - ), - ) - } - - !updateAuthRequest.requestApproved && - updateAuthRequest.responseDate != null -> { - isComplete = true - emit(CreateAuthRequestResult.Declined) - } - - updateAuthRequest - .creationDate - .toInstant() - .plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS) - .isBefore(clock.instant()) -> { - isComplete = true - emit(CreateAuthRequestResult.Expired) - } - - else -> { - authRequest = updateAuthRequest - emit(CreateAuthRequestResult.Update(authRequest)) - } - } - }, - ) - } - } - - private fun getAuthRequest( - initialRequest: suspend () -> AuthRequestUpdatesResult, - ): Flow = flow { - val result = initialRequest() - emit(result) - if (result is AuthRequestUpdatesResult.Error) return@flow - var isComplete = false - while (coroutineContext.isActive && !isComplete) { - delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS) - val updateResult = result as AuthRequestUpdatesResult.Update - authRequestsService - .getAuthRequest(result.authRequest.id) - .map { request -> - 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 = updateResult.authRequest.fingerprint, - ) - } - .fold( - onFailure = { emit(AuthRequestUpdatesResult.Error) }, - onSuccess = { updateAuthRequest -> - when { - updateAuthRequest.requestApproved -> { - isComplete = true - emit(AuthRequestUpdatesResult.Approved) - } - - !updateAuthRequest.requestApproved && - updateAuthRequest.responseDate != null -> { - isComplete = true - emit(AuthRequestUpdatesResult.Declined) - } - - updateAuthRequest - .creationDate - .toInstant() - .plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS) - .isBefore(clock.instant()) -> { - isComplete = true - emit(AuthRequestUpdatesResult.Expired) - } - - else -> { - emit(AuthRequestUpdatesResult.Update(updateAuthRequest)) - } - } - }, - ) - } - } - - override fun getAuthRequestByFingerprintFlow( - fingerprint: String, - ): Flow = getAuthRequest { - when (val authRequestsResult = getAuthRequests()) { - AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error - is AuthRequestsResult.Success -> { - authRequestsResult - .authRequests - .firstOrNull { it.fingerprint == fingerprint } - ?.let { AuthRequestUpdatesResult.Update(it) } - ?: AuthRequestUpdatesResult.Error - } - } - } - - override fun getAuthRequestByIdFlow( - requestId: String, - ): Flow = getAuthRequest { - authRequestsService - .getAuthRequest(requestId) - .map { 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, - ) - } - } - .fold( - onFailure = { AuthRequestUpdatesResult.Error }, - onSuccess = { authRequest -> - authRequest - ?.let { AuthRequestUpdatesResult.Update(it) } - ?: AuthRequestUpdatesResult.Error - }, - ) - } - - 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 updateAuthRequest( - requestId: String, - masterPasswordHash: String?, - publicKey: String, - isApproved: Boolean, - ): AuthRequestResult { - val userId = activeUserId ?: return AuthRequestResult.Error - return vaultSdkSource - .getAuthRequestKey( - publicKey = publicKey, - userId = userId, - ) - .flatMap { - authRequestsService.updateAuthRequest( - requestId = requestId, - key = it, - deviceId = authDiskSource.uniqueAppId, - masterPasswordHash = null, - isApproved = isApproved, - ) - } - .map { 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, - fingerprint = "", - ), - ) - } - .fold( - onFailure = { AuthRequestResult.Error }, - onSuccess = { it }, - ) - } - override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult = devicesService .getIsKnownDevice( @@ -1243,59 +953,6 @@ 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) }, - ) - } - - /** - * Attempts to create a new auth request for the given email and returns a [NewAuthRequestData] - * with the [AuthRequest] and [AuthRequestResponse]. - */ - private suspend fun createNewAuthRequest( - email: String, - ): Result = - authSdkSource - .getNewAuthRequest(email) - .flatMap { authRequestResponse -> - newAuthRequestService - .createAuthRequest( - email = email, - publicKey = authRequestResponse.publicKey, - deviceId = authDiskSource.uniqueAppId, - accessCode = authRequestResponse.accessCode, - fingerprint = authRequestResponse.fingerprint, - ) - .map { request -> - 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 = authRequestResponse.fingerprint, - ) - } - .map { NewAuthRequestData(it, authRequestResponse) } - } - /** * Get the remembered two-factor token associated with the user's email, if applicable. */ @@ -1344,11 +1001,3 @@ class AuthRepositoryImpl( ?.copy(accounts = accounts) } } - -/** - * Wrapper class for the [AuthRequest] and [AuthRequestResponse] data. - */ -private data class NewAuthRequestData( - val authRequest: AuthRequest, - val authRequestResponse: AuthRequestResponse, -) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 4b072e6e4..efb9082bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -2,13 +2,12 @@ package com.x8bit.bitwarden.data.auth.repository.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService -import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService -import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl @@ -23,7 +22,6 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import java.time.Clock import javax.inject.Singleton /** @@ -37,13 +35,10 @@ object AuthRepositoryModule { @Singleton @Suppress("LongParameterList") fun providesAuthRepository( - clock: Clock, accountsService: AccountsService, - authRequestsService: AuthRequestsService, devicesService: DevicesService, identityService: IdentityService, haveIBeenPwnedService: HaveIBeenPwnedService, - newAuthRequestService: NewAuthRequestService, organizationService: OrganizationService, authSdkSource: AuthSdkSource, vaultSdkSource: VaultSdkSource, @@ -52,16 +47,14 @@ object AuthRepositoryModule { environmentRepository: EnvironmentRepository, settingsRepository: SettingsRepository, vaultRepository: VaultRepository, + authRequestManager: AuthRequestManager, userLogoutManager: UserLogoutManager, pushManager: PushManager, policyManager: PolicyManager, ): AuthRepository = AuthRepositoryImpl( - clock = clock, accountsService = accountsService, - authRequestsService = authRequestsService, devicesService = devicesService, identityService = identityService, - newAuthRequestService = newAuthRequestService, organizationService = organizationService, authSdkSource = authSdkSource, vaultSdkSource = vaultSdkSource, @@ -71,6 +64,7 @@ object AuthRepositoryModule { environmentRepository = environmentRepository, settingsRepository = settingsRepository, vaultRepository = vaultRepository, + authRequestManager = authRequestManager, userLogoutManager = userLogoutManager, pushManager = pushManager, policyManager = policyManager, 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 bc8908a54..0283b4dd2 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 @@ -5,8 +5,8 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt index 4cab8acc8..ebc6999e9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt @@ -4,9 +4,9 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestUpdatesResult 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.AuthRequestUpdatesResult import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.ui.platform.base.BaseViewModel 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 3cb5a2b41..ac93a4959 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 @@ -3,9 +3,9 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult 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.AuthRequestsUpdatesResult import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerTest.kt new file mode 100644 index 000000000..f70aca8c2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManagerTest.kt @@ -0,0 +1,1032 @@ +package com.x8bit.bitwarden.data.auth.manager + +import app.cash.turbine.test +import com.bitwarden.core.AuthRequestResponse +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +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.datasource.network.model.AuthRequestsResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService +import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult +import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult +import com.x8bit.bitwarden.data.platform.util.asFailure +import com.x8bit.bitwarden.data.platform.util.asSuccess +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@Suppress("LargeClass") +class AuthRequestManagerTest { + private val fixedClock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + private val authRequestsService: AuthRequestsService = mockk() + private val newAuthRequestService: NewAuthRequestService = mockk() + private val authSdkSource: AuthSdkSource = mockk() + private val vaultSdkSource = mockk { + coEvery { + getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + } returns "AsymmetricEncString".asSuccess() + } + private val fakeAuthDiskSource = FakeAuthDiskSource() + + private val repository: AuthRequestManager = AuthRequestManagerImpl( + clock = fixedClock, + authRequestsService = authRequestsService, + newAuthRequestService = newAuthRequestService, + authSdkSource = authSdkSource, + vaultSdkSource = vaultSdkSource, + authDiskSource = fakeAuthDiskSource, + ) + + @Suppress("MaxLineLength") + @Test + fun `createAuthRequestWithUpdates with newAuthRequestService createAuthRequest error should emit Error`() = + runTest { + val email = "email@email.com" + val authRequestResponse = AUTH_REQUEST_RESPONSE + coEvery { + authSdkSource.getNewAuthRequest(email = email) + } returns authRequestResponse.asSuccess() + coEvery { + newAuthRequestService.createAuthRequest( + email = email, + publicKey = authRequestResponse.publicKey, + deviceId = fakeAuthDiskSource.uniqueAppId, + accessCode = authRequestResponse.accessCode, + fingerprint = authRequestResponse.fingerprint, + ) + } returns Throwable("Fail").asFailure() + + repository.createAuthRequestWithUpdates(email = email).test { + assertEquals(CreateAuthRequestResult.Error, awaitItem()) + awaitComplete() + } + } + + @Suppress("MaxLineLength") + @Test + fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with approval should emit Success`() = + runTest { + val email = "email@email.com" + val authRequestResponse = AUTH_REQUEST_RESPONSE + val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + ) + val updatedAuthRequestResponseJson = authRequestResponseJson.copy( + requestApproved = true, + ) + val authRequest = AuthRequest( + id = authRequestResponseJson.id, + publicKey = authRequestResponseJson.publicKey, + platform = authRequestResponseJson.platform, + ipAddress = authRequestResponseJson.ipAddress, + key = authRequestResponseJson.key, + masterPasswordHash = authRequestResponseJson.masterPasswordHash, + creationDate = authRequestResponseJson.creationDate, + responseDate = authRequestResponseJson.responseDate, + requestApproved = authRequestResponseJson.requestApproved ?: false, + originUrl = authRequestResponseJson.originUrl, + fingerprint = authRequestResponse.fingerprint, + ) + coEvery { + authSdkSource.getNewAuthRequest(email = email) + } returns authRequestResponse.asSuccess() + coEvery { + newAuthRequestService.createAuthRequest( + email = email, + publicKey = authRequestResponse.publicKey, + deviceId = fakeAuthDiskSource.uniqueAppId, + accessCode = authRequestResponse.accessCode, + fingerprint = authRequestResponse.fingerprint, + ) + } returns authRequestResponseJson.asSuccess() + coEvery { + newAuthRequestService.getAuthRequestUpdate( + requestId = authRequest.id, + accessCode = authRequestResponse.accessCode, + ) + } returnsMany listOf( + authRequestResponseJson.asSuccess(), + updatedAuthRequestResponseJson.asSuccess(), + ) + + repository.createAuthRequestWithUpdates(email = email).test { + assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) + assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) + assertEquals( + CreateAuthRequestResult.Success( + authRequest = authRequest.copy(requestApproved = true), + authRequestResponse = authRequestResponse, + ), + awaitItem(), + ) + awaitComplete() + } + } + + @Suppress("MaxLineLength") + @Test + fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with response date and no approval should emit Declined`() = + runTest { + val email = "email@email.com" + val authRequestResponse = AUTH_REQUEST_RESPONSE + val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + ) + val updatedAuthRequestResponseJson = authRequestResponseJson.copy( + responseDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + ) + val authRequest = AuthRequest( + id = authRequestResponseJson.id, + publicKey = authRequestResponseJson.publicKey, + platform = authRequestResponseJson.platform, + ipAddress = authRequestResponseJson.ipAddress, + key = authRequestResponseJson.key, + masterPasswordHash = authRequestResponseJson.masterPasswordHash, + creationDate = authRequestResponseJson.creationDate, + responseDate = authRequestResponseJson.responseDate, + requestApproved = authRequestResponseJson.requestApproved ?: false, + originUrl = authRequestResponseJson.originUrl, + fingerprint = authRequestResponse.fingerprint, + ) + coEvery { + authSdkSource.getNewAuthRequest(email = email) + } returns authRequestResponse.asSuccess() + coEvery { + newAuthRequestService.createAuthRequest( + email = email, + publicKey = authRequestResponse.publicKey, + deviceId = fakeAuthDiskSource.uniqueAppId, + accessCode = authRequestResponse.accessCode, + fingerprint = authRequestResponse.fingerprint, + ) + } returns authRequestResponseJson.asSuccess() + coEvery { + newAuthRequestService.getAuthRequestUpdate( + requestId = authRequest.id, + accessCode = authRequestResponse.accessCode, + ) + } returns updatedAuthRequestResponseJson.asSuccess() + + repository.createAuthRequestWithUpdates(email = email).test { + assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) + assertEquals(CreateAuthRequestResult.Declined, awaitItem()) + awaitComplete() + } + } + + @Suppress("MaxLineLength") + @Test + fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with old creation date should emit Expired`() = + runTest { + val email = "email@email.com" + val authRequestResponse = AUTH_REQUEST_RESPONSE + val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + ) + val updatedAuthRequestResponseJson = authRequestResponseJson.copy( + creationDate = ZonedDateTime.parse("2023-09-13T00:00Z"), + ) + val authRequest = AuthRequest( + id = authRequestResponseJson.id, + publicKey = authRequestResponseJson.publicKey, + platform = authRequestResponseJson.platform, + ipAddress = authRequestResponseJson.ipAddress, + key = authRequestResponseJson.key, + masterPasswordHash = authRequestResponseJson.masterPasswordHash, + creationDate = authRequestResponseJson.creationDate, + responseDate = authRequestResponseJson.responseDate, + requestApproved = authRequestResponseJson.requestApproved ?: false, + originUrl = authRequestResponseJson.originUrl, + fingerprint = authRequestResponse.fingerprint, + ) + coEvery { + authSdkSource.getNewAuthRequest(email = email) + } returns authRequestResponse.asSuccess() + coEvery { + newAuthRequestService.createAuthRequest( + email = email, + publicKey = authRequestResponse.publicKey, + deviceId = fakeAuthDiskSource.uniqueAppId, + accessCode = authRequestResponse.accessCode, + fingerprint = authRequestResponse.fingerprint, + ) + } returns authRequestResponseJson.asSuccess() + coEvery { + newAuthRequestService.getAuthRequestUpdate( + requestId = authRequest.id, + accessCode = authRequestResponse.accessCode, + ) + } returns updatedAuthRequestResponseJson.asSuccess() + + repository.createAuthRequestWithUpdates(email = email).test { + assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) + assertEquals(CreateAuthRequestResult.Expired, awaitItem()) + awaitComplete() + } + } + + @Suppress("MaxLineLength") + @Test + fun `createAuthRequestWithUpdates with authSdkSource getNewAuthRequest error should emit Error`() = + runTest { + val email = "email@email.com" + coEvery { + authSdkSource.getNewAuthRequest(email = email) + } returns Throwable("Fail").asFailure() + + repository.createAuthRequestWithUpdates(email = email).test { + assertEquals(CreateAuthRequestResult.Error, awaitItem()) + awaitComplete() + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByFingerprintFlow should emit failure and cancel flow when getAuthRequests fails`() = + runTest { + val fingerprint = "fingerprint" + coEvery { authRequestsService.getAuthRequests() } returns Throwable("Fail").asFailure() + + repository + .getAuthRequestByFingerprintFlow(fingerprint) + .test { + assertEquals(AuthRequestUpdatesResult.Error, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByFingerprintFlow should emit update then not cancel on failure when initial request succeeds and second fails`() = + runTest { + val authRequestsResponseJson = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val authRequest = AUTH_REQUEST + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = authRequest) + val expectedTwo = AuthRequestUpdatesResult.Error + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequests() + } returns authRequestsResponseJson.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns Throwable("Fail").asFailure() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByFingerprintFlow(FINGER_PRINT) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + cancelAndConsumeRemainingEvents() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByFingerprintFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() = + runTest { + val responseJsonOne = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + requestApproved = true, + ) + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Approved + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { authRequestsService.getAuthRequests() } returns responseJsonOne.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestsResponse.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByFingerprintFlow(FINGER_PRINT) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByFingerprintFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() = + runTest { + val responseJsonOne = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + responseDate = mockk(), + requestApproved = false, + ) + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Declined + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { authRequestsService.getAuthRequests() } returns responseJsonOne.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestsResponse.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByFingerprintFlow(FINGER_PRINT) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByFingerprintFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() = + runTest { + val responseJsonOne = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val fixedClock: Clock = Clock.fixed( + Instant.parse("2022-11-12T00:00:00Z"), + ZoneOffset.UTC, + ) + val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC), + requestApproved = false, + ) + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Expired + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequests() + } returns responseJsonOne.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestsResponse.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByFingerprintFlow(FINGER_PRINT) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByFingerprintFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() = + runTest { + val responseJsonOne = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val newHash = "evenMoreSecureHash" + val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + masterPasswordHash = newHash, + requestApproved = false, + ) + val authRequest = AUTH_REQUEST.copy( + masterPasswordHash = newHash, + requestApproved = false, + ) + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Update(authRequest = authRequest) + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { authRequestsService.getAuthRequests() } returns responseJsonOne.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns Result.success(authRequestsResponse) + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByFingerprintFlow(FINGER_PRINT) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + cancelAndConsumeRemainingEvents() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByIdFlow should emit failure and cancel flow when getAuthRequests fails`() = + runTest { + coEvery { + authRequestsService.getAuthRequest(REQUEST_ID) + } returns Throwable("Fail").asFailure() + + repository + .getAuthRequestByIdFlow(REQUEST_ID) + .test { + assertEquals(AuthRequestUpdatesResult.Error, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authRequestsService.getAuthRequest(REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByIdFlow should emit update then not cancel on failure when initial request succeeds and second fails`() = + runTest { + val authRequestResponseOne = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.asSuccess() + val authRequestResponseTwo = Throwable("Fail").asFailure() + val authRequest = AUTH_REQUEST.copy(id = REQUEST_ID) + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = authRequest) + val expectedTwo = AuthRequestUpdatesResult.Error + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestResponseOne andThen authRequestResponseTwo + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByIdFlow(REQUEST_ID) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + cancelAndConsumeRemainingEvents() + } + + coVerify(exactly = 1) { + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + coVerify(exactly = 2) { + authRequestsService.getAuthRequest(REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByIdFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() = + runTest { + val authRequestResponseOne = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.asSuccess() + val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + requestApproved = true, + ) + val authRequestResponseTwo = authRequestResponseJson.asSuccess() + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Approved + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestResponseOne andThen authRequestResponseTwo + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByIdFlow(REQUEST_ID) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + coVerify(exactly = 2) { + authRequestsService.getAuthRequest(REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByIdFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() = + runTest { + val authRequestResponseOne = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.asSuccess() + val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + responseDate = mockk(), + requestApproved = false, + ) + val authRequestResponseTwo = authRequestResponseJson.asSuccess() + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Declined + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestResponseOne andThen authRequestResponseTwo + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByIdFlow(REQUEST_ID) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + coVerify(exactly = 2) { + authRequestsService.getAuthRequest(REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByIdFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() = + runTest { + val fixedClock: Clock = Clock.fixed( + Instant.parse("2022-11-12T00:00:00Z"), + ZoneOffset.UTC, + ) + val authRequestResponseOne = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.asSuccess() + val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC), + requestApproved = false, + ) + val authRequestResponseTwo = authRequestResponseJson.asSuccess() + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Expired + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestResponseOne andThen authRequestResponseTwo + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByIdFlow(REQUEST_ID) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + coVerify(exactly = 2) { + authRequestsService.getAuthRequest(REQUEST_ID) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestByIdFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() = + runTest { + val newHash = "evenMoreSecureHash" + val authRequestResponseOne = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.asSuccess() + val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( + masterPasswordHash = newHash, + requestApproved = false, + ) + val authRequestResponseTwo = authRequestResponseJson.asSuccess() + val authRequest = AUTH_REQUEST.copy( + masterPasswordHash = newHash, + requestApproved = false, + ) + val expectedOne = AuthRequestUpdatesResult.Update(authRequest = AUTH_REQUEST) + val expectedTwo = AuthRequestUpdatesResult.Update(authRequest = authRequest) + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + coEvery { + authRequestsService.getAuthRequest(requestId = REQUEST_ID) + } returns authRequestResponseOne andThen authRequestResponseTwo + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestByIdFlow(REQUEST_ID) + .test { + assertEquals(expectedOne, awaitItem()) + assertEquals(expectedTwo, awaitItem()) + cancelAndConsumeRemainingEvents() + } + + coVerify(exactly = 1) { + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + coVerify(exactly = 2) { + authRequestsService.getAuthRequest(REQUEST_ID) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("MaxLineLength") + @Test + fun `getAuthRequestsWithUpdates should emit error then success and not cancel flow when getAuthRequests fails then succeeds`() = + runTest { + val threeMinutes = 3L * 60L * 1_000L + val authRequests = listOf(AUTH_REQUEST) + val authRequestsResponseJson = AuthRequestsResponseJson( + authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), + ) + val expectedOne = AuthRequestsUpdatesResult.Error + val expectedTwo = AuthRequestsUpdatesResult.Update(authRequests = authRequests) + coEvery { + authRequestsService.getAuthRequests() + } returns Throwable("Fail").asFailure() andThen authRequestsResponseJson.asSuccess() + coEvery { + authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY) + } returns FINGER_PRINT.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + repository + .getAuthRequestsWithUpdates() + .test { + assertEquals(expectedOne, awaitItem()) + advanceTimeBy(threeMinutes) + expectNoEvents() + advanceTimeBy(threeMinutes) + assertEquals(expectedTwo, awaitItem()) + advanceTimeBy(threeMinutes) + cancelAndIgnoreRemainingEvents() + } + + coVerify(exactly = 2) { + authRequestsService.getAuthRequests() + } + } + + @Test + fun `getAuthRequests should return failure when service returns failure`() = runTest { + coEvery { authRequestsService.getAuthRequests() } returns Throwable("Fail").asFailure() + + val result = repository.getAuthRequests() + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + assertEquals(AuthRequestsResult.Error, result) + } + + @Test + fun `getAuthRequests should return success when service returns success`() = runTest { + val fingerprint = "fingerprint" + val responseJson = AuthRequestsResponseJson( + authRequests = listOf( + AuthRequestsResponseJson.AuthRequest( + id = "1", + 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", + ), + ), + ) + val expected = AuthRequestsResult.Success( + authRequests = listOf( + AuthRequest( + id = "1", + 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 + + 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", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + ), + ), + ) + val expected = AuthRequestsResult.Success(emptyList()) + coEvery { authRequestsService.getAuthRequests() } returns responseJson.asSuccess() + + val result = repository.getAuthRequests() + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + assertEquals(expected, result) + } + + @Test + fun `updateAuthRequest should return failure when sdk returns failure`() = runTest { + coEvery { + vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + } returns Throwable("Fail").asFailure() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + val result = repository.updateAuthRequest( + requestId = "requestId", + masterPasswordHash = "masterPasswordHash", + publicKey = PUBLIC_KEY, + isApproved = false, + ) + + coVerify(exactly = 1) { + vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + } + assertEquals(AuthRequestResult.Error, result) + } + + @Test + fun `updateAuthRequest should return failure when service returns failure`() = runTest { + val requestId = "requestId" + val passwordHash = "masterPasswordHash" + val encodedKey = "encodedKey" + coEvery { + vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + } returns encodedKey.asSuccess() + coEvery { + authRequestsService.updateAuthRequest( + requestId = requestId, + masterPasswordHash = null, + key = encodedKey, + deviceId = UNIQUE_APP_ID, + isApproved = false, + ) + } returns Throwable("Mission failed").asFailure() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + val result = repository.updateAuthRequest( + requestId = requestId, + masterPasswordHash = passwordHash, + publicKey = PUBLIC_KEY, + isApproved = false, + ) + + coVerify(exactly = 1) { + vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + } + assertEquals(AuthRequestResult.Error, result) + } + + @Test + fun `updateAuthRequest should return success when service & sdk return success`() = runTest { + val requestId = "requestId" + val passwordHash = "masterPasswordHash" + val encodedKey = "encodedKey" + val responseJson = AuthRequestsResponseJson.AuthRequest( + id = requestId, + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "key", + masterPasswordHash = passwordHash, + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + ) + val expected = AuthRequestResult.Success( + authRequest = AuthRequest( + id = requestId, + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "key", + masterPasswordHash = passwordHash, + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = "", + ), + ) + coEvery { + vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + } returns encodedKey.asSuccess() + coEvery { + authRequestsService.updateAuthRequest( + requestId = requestId, + masterPasswordHash = null, + key = encodedKey, + deviceId = UNIQUE_APP_ID, + isApproved = false, + ) + } returns responseJson.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE + + val result = repository.updateAuthRequest( + requestId = requestId, + masterPasswordHash = passwordHash, + publicKey = PUBLIC_KEY, + isApproved = false, + ) + + coVerify(exactly = 1) { + vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID) + authRequestsService.updateAuthRequest( + requestId = requestId, + masterPasswordHash = null, + key = encodedKey, + deviceId = UNIQUE_APP_ID, + isApproved = false, + ) + } + assertEquals(expected, result) + } +} + +private const val EMAIL: String = "test@bitwarden.com" +private const val UNIQUE_APP_ID: String = "testUniqueAppId" +private const val PRIVATE_KEY: String = "privateKey" +private const val PUBLIC_KEY: String = "PublicKey" +private const val USER_ID: String = "2a135b23-e1fb-42c9-bec3-573857bc8181" +private const val ACCESS_TOKEN: String = "accessToken" +private const val REFRESH_TOKEN: String = "refreshToken" +private const val REQUEST_ID: String = "REQUEST_ID" +private const val FINGER_PRINT: String = "FINGER_PRINT" + +private val ACCOUNT: AccountJson = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID, + email = EMAIL, + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = null, + ), + tokens = AccountJson.Tokens( + accessToken = ACCESS_TOKEN, + refreshToken = REFRESH_TOKEN, + ), + settings = AccountJson.Settings( + environmentUrlData = null, + ), +) + +private val SINGLE_USER_STATE: UserStateJson = UserStateJson( + activeUserId = USER_ID, + accounts = mapOf( + USER_ID to ACCOUNT, + ), +) + +private val AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE: AuthRequestsResponseJson.AuthRequest = + AuthRequestsResponseJson.AuthRequest( + id = REQUEST_ID, + 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", + ) + +private val AUTH_REQUEST: AuthRequest = AuthRequest( + id = REQUEST_ID, + 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 = FINGER_PRINT, +) + +private val AUTH_REQUEST_RESPONSE: AuthRequestResponse = AuthRequestResponse( + privateKey = PRIVATE_KEY, + publicKey = PUBLIC_KEY, + accessCode = "accessCode", + fingerprint = "fingerprint", +) 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 db9e16023..1dda10c02 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 @@ -14,7 +14,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason 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.datasource.network.model.AuthRequestsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson @@ -30,11 +29,9 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordReque import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService -import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService -import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0 @@ -42,15 +39,10 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4 +import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager -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.AuthRequestUpdatesResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsUpdatesResult import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult -import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult @@ -101,9 +93,7 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.JsonNull @@ -118,25 +108,16 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset import java.time.ZonedDateTime @Suppress("LargeClass") class AuthRepositoryTest { - private val fixedClock: Clock = Clock.fixed( - Instant.parse("2023-10-27T12:00:00Z"), - ZoneOffset.UTC, - ) private val dispatcherManager: DispatcherManager = FakeDispatcherManager() private val accountsService: AccountsService = mockk() - private val authRequestsService: AuthRequestsService = mockk() private val devicesService: DevicesService = mockk() private val identityService: IdentityService = mockk() private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk() - private val newAuthRequestService: NewAuthRequestService = mockk() private val organizationService: OrganizationService = mockk() private val mutableVaultUnlockDataStateFlow = MutableStateFlow(VAULT_UNLOCK_DATA) private val vaultRepository: VaultRepository = mockk { @@ -199,6 +180,7 @@ class AuthRepositoryTest { ) } returns "AsymmetricEncString".asSuccess() } + private val authRequestManager: AuthRequestManager = mockk() private val userLogoutManager: UserLogoutManager = mockk { every { logout(any(), any()) } just runs } @@ -219,13 +201,10 @@ class AuthRepositoryTest { private var elapsedRealtimeMillis = 123456789L private val repository = AuthRepositoryImpl( - clock = fixedClock, accountsService = accountsService, - authRequestsService = authRequestsService, devicesService = devicesService, identityService = identityService, haveIBeenPwnedService = haveIBeenPwnedService, - newAuthRequestService = newAuthRequestService, organizationService = organizationService, authSdkSource = authSdkSource, vaultSdkSource = vaultSdkSource, @@ -233,6 +212,7 @@ class AuthRepositoryTest { environmentRepository = fakeEnvironmentRepository, settingsRepository = settingsRepository, vaultRepository = vaultRepository, + authRequestManager = authRequestManager, userLogoutManager = userLogoutManager, dispatcherManager = dispatcherManager, pushManager = pushManager, @@ -2784,998 +2764,6 @@ class AuthRepositoryTest { ) } - @Suppress("MaxLineLength") - @Test - fun `createAuthRequestWithUpdates with authSdkSource getNewAuthRequest error should emit Error`() = - runTest { - val email = "email@email.com" - coEvery { - authSdkSource.getNewAuthRequest(email = email) - } returns Throwable("Fail").asFailure() - - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Error, awaitItem()) - awaitComplete() - } - } - - @Suppress("MaxLineLength") - @Test - fun `createAuthRequestWithUpdates with newAuthRequestService createAuthRequest error should emit Error`() = - runTest { - val email = "email@email.com" - val authRequestResponse = AUTH_REQUEST_RESPONSE - coEvery { - authSdkSource.getNewAuthRequest(email = email) - } returns authRequestResponse.asSuccess() - coEvery { - newAuthRequestService.createAuthRequest( - email = email, - publicKey = authRequestResponse.publicKey, - deviceId = fakeAuthDiskSource.uniqueAppId, - accessCode = authRequestResponse.accessCode, - fingerprint = authRequestResponse.fingerprint, - ) - } returns Throwable("Fail").asFailure() - - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Error, awaitItem()) - awaitComplete() - } - } - - @Suppress("MaxLineLength") - @Test - fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with approval should emit Success`() = - runTest { - val email = "email@email.com" - val authRequestResponse = AUTH_REQUEST_RESPONSE - val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest( - id = "1", - publicKey = PUBLIC_KEY, - platform = "Android", - ipAddress = "192.168.0.1", - key = "public", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - responseDate = null, - requestApproved = false, - originUrl = "www.bitwarden.com", - ) - val updatedAuthRequestResponseJson = authRequestResponseJson.copy( - requestApproved = true, - ) - val authRequest = AuthRequest( - id = authRequestResponseJson.id, - publicKey = authRequestResponseJson.publicKey, - platform = authRequestResponseJson.platform, - ipAddress = authRequestResponseJson.ipAddress, - key = authRequestResponseJson.key, - masterPasswordHash = authRequestResponseJson.masterPasswordHash, - creationDate = authRequestResponseJson.creationDate, - responseDate = authRequestResponseJson.responseDate, - requestApproved = authRequestResponseJson.requestApproved ?: false, - originUrl = authRequestResponseJson.originUrl, - fingerprint = authRequestResponse.fingerprint, - ) - coEvery { - authSdkSource.getNewAuthRequest(email = email) - } returns authRequestResponse.asSuccess() - coEvery { - newAuthRequestService.createAuthRequest( - email = email, - publicKey = authRequestResponse.publicKey, - deviceId = fakeAuthDiskSource.uniqueAppId, - accessCode = authRequestResponse.accessCode, - fingerprint = authRequestResponse.fingerprint, - ) - } returns authRequestResponseJson.asSuccess() - coEvery { - newAuthRequestService.getAuthRequestUpdate( - requestId = authRequest.id, - accessCode = authRequestResponse.accessCode, - ) - } returnsMany listOf( - authRequestResponseJson.asSuccess(), - updatedAuthRequestResponseJson.asSuccess(), - ) - - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) - assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) - assertEquals( - CreateAuthRequestResult.Success( - authRequest = authRequest.copy(requestApproved = true), - authRequestResponse = authRequestResponse, - ), - awaitItem(), - ) - awaitComplete() - } - } - - @Suppress("MaxLineLength") - @Test - fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with response date and no approval should emit Declined`() = - runTest { - val email = "email@email.com" - val authRequestResponse = AUTH_REQUEST_RESPONSE - val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest( - id = "1", - publicKey = PUBLIC_KEY, - platform = "Android", - ipAddress = "192.168.0.1", - key = "public", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - responseDate = null, - requestApproved = false, - originUrl = "www.bitwarden.com", - ) - val updatedAuthRequestResponseJson = authRequestResponseJson.copy( - responseDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - ) - val authRequest = AuthRequest( - id = authRequestResponseJson.id, - publicKey = authRequestResponseJson.publicKey, - platform = authRequestResponseJson.platform, - ipAddress = authRequestResponseJson.ipAddress, - key = authRequestResponseJson.key, - masterPasswordHash = authRequestResponseJson.masterPasswordHash, - creationDate = authRequestResponseJson.creationDate, - responseDate = authRequestResponseJson.responseDate, - requestApproved = authRequestResponseJson.requestApproved ?: false, - originUrl = authRequestResponseJson.originUrl, - fingerprint = authRequestResponse.fingerprint, - ) - coEvery { - authSdkSource.getNewAuthRequest(email = email) - } returns authRequestResponse.asSuccess() - coEvery { - newAuthRequestService.createAuthRequest( - email = email, - publicKey = authRequestResponse.publicKey, - deviceId = fakeAuthDiskSource.uniqueAppId, - accessCode = authRequestResponse.accessCode, - fingerprint = authRequestResponse.fingerprint, - ) - } returns authRequestResponseJson.asSuccess() - coEvery { - newAuthRequestService.getAuthRequestUpdate( - requestId = authRequest.id, - accessCode = authRequestResponse.accessCode, - ) - } returns updatedAuthRequestResponseJson.asSuccess() - - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) - assertEquals(CreateAuthRequestResult.Declined, awaitItem()) - awaitComplete() - } - } - - @Suppress("MaxLineLength") - @Test - fun `createAuthRequestWithUpdates with createNewAuthRequest Success and getAuthRequestUpdate with old creation date should emit Expired`() = - runTest { - val email = "email@email.com" - val authRequestResponse = AUTH_REQUEST_RESPONSE - val authRequestResponseJson = AuthRequestsResponseJson.AuthRequest( - id = "1", - publicKey = PUBLIC_KEY, - platform = "Android", - ipAddress = "192.168.0.1", - key = "public", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - responseDate = null, - requestApproved = false, - originUrl = "www.bitwarden.com", - ) - val updatedAuthRequestResponseJson = authRequestResponseJson.copy( - creationDate = ZonedDateTime.parse("2023-09-13T00:00Z"), - ) - val authRequest = AuthRequest( - id = authRequestResponseJson.id, - publicKey = authRequestResponseJson.publicKey, - platform = authRequestResponseJson.platform, - ipAddress = authRequestResponseJson.ipAddress, - key = authRequestResponseJson.key, - masterPasswordHash = authRequestResponseJson.masterPasswordHash, - creationDate = authRequestResponseJson.creationDate, - responseDate = authRequestResponseJson.responseDate, - requestApproved = authRequestResponseJson.requestApproved ?: false, - originUrl = authRequestResponseJson.originUrl, - fingerprint = authRequestResponse.fingerprint, - ) - coEvery { - authSdkSource.getNewAuthRequest(email = email) - } returns authRequestResponse.asSuccess() - coEvery { - newAuthRequestService.createAuthRequest( - email = email, - publicKey = authRequestResponse.publicKey, - deviceId = fakeAuthDiskSource.uniqueAppId, - accessCode = authRequestResponse.accessCode, - fingerprint = authRequestResponse.fingerprint, - ) - } returns authRequestResponseJson.asSuccess() - coEvery { - newAuthRequestService.getAuthRequestUpdate( - requestId = authRequest.id, - accessCode = authRequestResponse.accessCode, - ) - } returns updatedAuthRequestResponseJson.asSuccess() - - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) - assertEquals(CreateAuthRequestResult.Expired, awaitItem()) - awaitComplete() - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByFingerprintFlow should emit failure and cancel flow when getAuthRequests fails`() = - runTest { - val fingerprint = "fingerprint" - coEvery { - authRequestsService.getAuthRequests() - } returns Throwable("Fail").asFailure() - - repository - .getAuthRequestByFingerprintFlow(fingerprint) - .test { - assertEquals(AuthRequestUpdatesResult.Error, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByFingerprintFlow should emit update then not cancel on failure when initial request succeeds and second fails`() = - runTest { - val authRequestsResponseJson = AuthRequestsResponseJson( - authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), - ) - val authRequest = AUTH_REQUEST - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = authRequest, - ) - val expectedTwo = AuthRequestUpdatesResult.Error - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequests() - } returns authRequestsResponseJson.asSuccess() - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns Result.failure(mockk()) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByFingerprintFlow(FINGER_PRINT) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - cancelAndConsumeRemainingEvents() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByFingerprintFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() = - runTest { - val responseJsonOne = AuthRequestsResponseJson( - authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), - ) - val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - requestApproved = true, - ) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Approved - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequests() - } returns responseJsonOne.asSuccess() - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns Result.success(authRequestsResponse) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByFingerprintFlow(FINGER_PRINT) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByFingerprintFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() = - runTest { - val responseJsonOne = AuthRequestsResponseJson( - authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), - ) - val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - responseDate = mockk(), - requestApproved = false, - ) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Declined - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequests() - } returns responseJsonOne.asSuccess() - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns Result.success(authRequestsResponse) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByFingerprintFlow(FINGER_PRINT) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByFingerprintFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() = - runTest { - val responseJsonOne = AuthRequestsResponseJson( - authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), - ) - val fixedClock: Clock = Clock.fixed( - Instant.parse("2022-11-12T00:00:00Z"), - ZoneOffset.UTC, - ) - val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC), - requestApproved = false, - ) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Expired - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequests() - } returns responseJsonOne.asSuccess() - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns Result.success(authRequestsResponse) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByFingerprintFlow(FINGER_PRINT) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByFingerprintFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() = - runTest { - val responseJsonOne = AuthRequestsResponseJson( - authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), - ) - val newHash = "evenMoreSecureHash" - val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - masterPasswordHash = newHash, - requestApproved = false, - ) - val authRequest = AUTH_REQUEST.copy( - masterPasswordHash = newHash, - requestApproved = false, - ) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Update( - authRequest = authRequest, - ) - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequests() - } returns responseJsonOne.asSuccess() - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns Result.success(authRequestsResponse) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByFingerprintFlow(FINGER_PRINT) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - cancelAndConsumeRemainingEvents() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByIdFlow should emit failure and cancel flow when getAuthRequests fails`() = - runTest { - coEvery { - authRequestsService.getAuthRequest(REQUEST_ID) - } returns Throwable("Fail").asFailure() - - repository - .getAuthRequestByIdFlow(REQUEST_ID) - .test { - assertEquals(AuthRequestUpdatesResult.Error, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authRequestsService.getAuthRequest(REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByIdFlow should emit update then not cancel on failure when initial request succeeds and second fails`() = - runTest { - val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE) - val authRequestResponseTwo = Result.failure( - exception = mockk(), - ) - val authRequest = AUTH_REQUEST.copy( - id = REQUEST_ID, - ) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = authRequest, - ) - val expectedTwo = AuthRequestUpdatesResult.Error - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns authRequestResponseOne andThen authRequestResponseTwo - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByIdFlow(REQUEST_ID) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - cancelAndConsumeRemainingEvents() - } - - coVerify(exactly = 1) { - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - } - coVerify(exactly = 2) { - authRequestsService.getAuthRequest(REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByIdFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() = - runTest { - val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE) - val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - requestApproved = true, - ) - val authRequestResponseTwo = Result.success(authRequestResponseJson) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Approved - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns authRequestResponseOne andThen authRequestResponseTwo - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByIdFlow(REQUEST_ID) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - } - coVerify(exactly = 2) { - authRequestsService.getAuthRequest(REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByIdFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() = - runTest { - val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE) - val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - responseDate = mockk(), - requestApproved = false, - ) - val authRequestResponseTwo = Result.success(authRequestResponseJson) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Declined - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns authRequestResponseOne andThen authRequestResponseTwo - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByIdFlow(REQUEST_ID) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - } - coVerify(exactly = 2) { - authRequestsService.getAuthRequest(REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByIdFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() = - runTest { - val fixedClock: Clock = Clock.fixed( - Instant.parse("2022-11-12T00:00:00Z"), - ZoneOffset.UTC, - ) - val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE) - val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC), - requestApproved = false, - ) - val authRequestResponseTwo = Result.success(authRequestResponseJson) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Expired - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns authRequestResponseOne andThen authRequestResponseTwo - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByIdFlow(REQUEST_ID) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - awaitComplete() - } - - coVerify(exactly = 1) { - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - } - coVerify(exactly = 2) { - authRequestsService.getAuthRequest(REQUEST_ID) - } - } - - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestByIdFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() = - runTest { - val newHash = "evenMoreSecureHash" - val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE) - val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy( - masterPasswordHash = newHash, - requestApproved = false, - ) - val authRequestResponseTwo = Result.success(authRequestResponseJson) - val authRequest = AUTH_REQUEST.copy( - masterPasswordHash = newHash, - requestApproved = false, - ) - val expectedOne = AuthRequestUpdatesResult.Update( - authRequest = AUTH_REQUEST, - ) - val expectedTwo = AuthRequestUpdatesResult.Update( - authRequest = authRequest, - ) - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - coEvery { - authRequestsService.getAuthRequest(requestId = REQUEST_ID) - } returns authRequestResponseOne andThen authRequestResponseTwo - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestByIdFlow(REQUEST_ID) - .test { - assertEquals(expectedOne, awaitItem()) - assertEquals(expectedTwo, awaitItem()) - cancelAndConsumeRemainingEvents() - } - - coVerify(exactly = 1) { - authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) - } - coVerify(exactly = 2) { - authRequestsService.getAuthRequest(REQUEST_ID) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Suppress("MaxLineLength") - @Test - fun `getAuthRequestsWithUpdates should emit error then success and not cancel flow when getAuthRequests fails then succeeds`() = - runTest { - val threeMinutes = 3L * 60L * 1_000L - val authRequests = listOf(AUTH_REQUEST) - val authRequestsResponseJson = AuthRequestsResponseJson( - authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE), - ) - val expectedOne = AuthRequestsUpdatesResult.Error - val expectedTwo = AuthRequestsUpdatesResult.Update(authRequests = authRequests) - coEvery { - authRequestsService.getAuthRequests() - } returns Throwable("Fail").asFailure() andThen authRequestsResponseJson.asSuccess() - coEvery { - authSdkSource.getUserFingerprint( - email = EMAIL, - publicKey = PUBLIC_KEY, - ) - } returns Result.success(FINGER_PRINT) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - repository - .getAuthRequestsWithUpdates() - .test { - assertEquals(expectedOne, awaitItem()) - advanceTimeBy(threeMinutes) - expectNoEvents() - advanceTimeBy(threeMinutes) - assertEquals(expectedTwo, awaitItem()) - advanceTimeBy(threeMinutes) - cancelAndIgnoreRemainingEvents() - } - - coVerify(exactly = 2) { - authRequestsService.getAuthRequests() - } - } - - @Test - fun `getAuthRequests should return failure when service returns failure`() = runTest { - coEvery { - authRequestsService.getAuthRequests() - } returns Throwable("Fail").asFailure() - - val result = repository.getAuthRequests() - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - } - assertEquals(AuthRequestsResult.Error, result) - } - - @Test - fun `getAuthRequests should return success when service returns success`() = runTest { - val fingerprint = "fingerprint" - val responseJson = AuthRequestsResponseJson( - authRequests = listOf( - AuthRequestsResponseJson.AuthRequest( - id = "1", - 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", - ), - ), - ) - val expected = AuthRequestsResult.Success( - authRequests = listOf( - AuthRequest( - id = "1", - 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", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - responseDate = null, - requestApproved = true, - originUrl = "www.bitwarden.com", - ), - ), - ) - val expected = AuthRequestsResult.Success(emptyList()) - coEvery { - authRequestsService.getAuthRequests() - } returns responseJson.asSuccess() - - val result = repository.getAuthRequests() - - coVerify(exactly = 1) { - authRequestsService.getAuthRequests() - } - assertEquals(expected, result) - } - - @Test - fun `updateAuthRequest should return failure when sdk returns failure`() = runTest { - coEvery { - vaultSdkSource.getAuthRequestKey( - publicKey = PUBLIC_KEY, - userId = USER_ID_1, - ) - } returns Throwable("Fail").asFailure() - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - val result = repository.updateAuthRequest( - requestId = "requestId", - masterPasswordHash = "masterPasswordHash", - publicKey = PUBLIC_KEY, - isApproved = false, - ) - - coVerify(exactly = 1) { - vaultSdkSource.getAuthRequestKey( - publicKey = PUBLIC_KEY, - userId = USER_ID_1, - ) - } - assertEquals(AuthRequestResult.Error, result) - } - - @Test - fun `updateAuthRequest should return failure when service returns failure`() = runTest { - val requestId = "requestId" - val passwordHash = "masterPasswordHash" - val encodedKey = "encodedKey" - coEvery { - vaultSdkSource.getAuthRequestKey( - publicKey = PUBLIC_KEY, - userId = USER_ID_1, - ) - } returns encodedKey.asSuccess() - coEvery { - authRequestsService.updateAuthRequest( - requestId = requestId, - masterPasswordHash = null, - key = encodedKey, - deviceId = UNIQUE_APP_ID, - isApproved = false, - ) - } returns Throwable("Mission failed").asFailure() - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - val result = repository.updateAuthRequest( - requestId = requestId, - masterPasswordHash = passwordHash, - publicKey = PUBLIC_KEY, - isApproved = false, - ) - - coVerify(exactly = 1) { - vaultSdkSource.getAuthRequestKey( - publicKey = PUBLIC_KEY, - userId = USER_ID_1, - ) - } - assertEquals(AuthRequestResult.Error, result) - } - - @Suppress("LongMethod") - @Test - fun `updateAuthRequest should return success when service & sdk return success`() = runTest { - val requestId = "requestId" - val passwordHash = "masterPasswordHash" - val encodedKey = "encodedKey" - val responseJson = AuthRequestsResponseJson.AuthRequest( - id = requestId, - publicKey = PUBLIC_KEY, - platform = "Android", - ipAddress = "192.168.0.1", - key = "key", - masterPasswordHash = passwordHash, - creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - responseDate = null, - requestApproved = true, - originUrl = "www.bitwarden.com", - ) - val expected = AuthRequestResult.Success( - authRequest = AuthRequest( - id = requestId, - publicKey = PUBLIC_KEY, - platform = "Android", - ipAddress = "192.168.0.1", - key = "key", - masterPasswordHash = passwordHash, - creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), - responseDate = null, - requestApproved = true, - originUrl = "www.bitwarden.com", - fingerprint = "", - ), - ) - coEvery { - vaultSdkSource.getAuthRequestKey( - publicKey = PUBLIC_KEY, - userId = USER_ID_1, - ) - } returns encodedKey.asSuccess() - coEvery { - authRequestsService.updateAuthRequest( - requestId = requestId, - masterPasswordHash = null, - key = encodedKey, - deviceId = UNIQUE_APP_ID, - isApproved = false, - ) - } returns responseJson.asSuccess() - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - - val result = repository.updateAuthRequest( - requestId = requestId, - masterPasswordHash = passwordHash, - publicKey = PUBLIC_KEY, - isApproved = false, - ) - - coVerify(exactly = 1) { - vaultSdkSource.getAuthRequestKey( - publicKey = PUBLIC_KEY, - userId = USER_ID_1, - ) - authRequestsService.updateAuthRequest( - requestId = requestId, - masterPasswordHash = null, - key = encodedKey, - deviceId = UNIQUE_APP_ID, - isApproved = false, - ) - } - assertEquals(expected, result) - } - @Test fun `getIsKnownDevice should return failure when service returns failure`() = runTest { coEvery { @@ -4283,33 +3271,5 @@ class AuthRepositoryTest { status = VaultUnlockData.Status.UNLOCKED, ), ) - private const val FINGER_PRINT = "FINGER_PRINT" - private const val REQUEST_ID: String = "REQUEST_ID" - private val AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE = - AuthRequestsResponseJson.AuthRequest( - id = REQUEST_ID, - 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", - ) - private val AUTH_REQUEST = AuthRequest( - id = REQUEST_ID, - 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 = FINGER_PRINT, - ) } } 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 c6c30cdce..0a81593a5 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,9 +4,9 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.AuthRequestResponse import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult 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.CreateAuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt index 66552e191..58accc4f6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt @@ -3,10 +3,10 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginap import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestUpdatesResult 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.AuthRequestUpdatesResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData 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 db0c3c9ef..2af91b753 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 @@ -2,11 +2,11 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsResult +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult 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.AuthRequestsResult -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsUpdatesResult import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest