From b2005f01c134fb4f430738a6734c4a7bdc939c3f Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 1 Apr 2024 10:34:05 -0500 Subject: [PATCH] Update LoginWithDeviceScreen to support Admin Approval type (#1175) --- .../api/AuthenticatedAuthRequestsApi.kt | 12 ++ .../network/di/AuthNetworkModule.kt | 1 + .../network/service/NewAuthRequestService.kt | 4 + .../service/NewAuthRequestServiceImpl.kt | 62 ++++-- .../data/auth/manager/AuthRequestManager.kt | 6 +- .../auth/manager/AuthRequestManagerImpl.kt | 21 +- .../auth/manager/model/AuthRequestType.kt | 10 + .../manager/util/AuthRequestTypeExtensions.kt | 27 +++ .../ui/auth/feature/auth/AuthNavigation.kt | 2 + .../LoginWithDeviceNavigation.kt | 25 ++- .../loginwithdevice/LoginWithDeviceScreen.kt | 53 ++--- .../LoginWithDeviceViewModel.kt | 136 ++++++++++-- .../model/LoginWithDeviceType.kt | 12 ++ .../util/LoginWithDeviceTypeExtensions.kt | 14 ++ .../service/NewAuthRequestServiceTest.kt | 202 ++++++++++++++---- .../auth/manager/AuthRequestManagerTest.kt | 92 +++++--- .../util/AuthRequestTypeExtensionsTest.kt | 32 +++ .../data/platform/base/BaseServiceTest.kt | 2 + .../LoginWithDeviceScreenTest.kt | 3 + .../LoginWithDeviceViewModelTest.kt | 64 +++++- .../util/LoginWithDeviceTypeExtensionsTest.kt | 20 ++ .../ui/platform/base/BaseViewModelTest.kt | 19 ++ 22 files changed, 675 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestType.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/model/LoginWithDeviceType.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAuthRequestsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAuthRequestsApi.kt index 36b192b12c..52b711f3f0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAuthRequestsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAuthRequestsApi.kt @@ -1,9 +1,12 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -12,6 +15,15 @@ import retrofit2.http.Path */ interface AuthenticatedAuthRequestsApi { + /** + * Notifies the server of a new admin authentication request. + */ + @POST("/auth-requests/admin-request") + suspend fun createAdminAuthRequest( + @Header("Device-Identifier") deviceIdentifier: String, + @Body body: AuthRequestRequestJson, + ): Result + /** * Updates an authentication request. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt index 2624401b33..9b9b90d756 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt @@ -85,6 +85,7 @@ object AuthNetworkModule { fun providesNewAuthRequestService( retrofits: Retrofits, ): NewAuthRequestService = NewAuthRequestServiceImpl( + authenticatedAuthRequestsApi = retrofits.authenticatedApiRetrofit.create(), unauthenticatedAuthRequestsApi = retrofits.unauthenticatedApiRetrofit.create(), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestService.kt index ddf335db5f..dff57021af 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestService.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson /** @@ -9,12 +10,14 @@ interface NewAuthRequestService { /** * Informs the server of a new auth request in order to notify approving devices. */ + @Suppress("LongParameterList") suspend fun createAuthRequest( email: String, publicKey: String, deviceId: String, accessCode: String, fingerprint: String, + authRequestType: AuthRequestTypeJson, ): Result /** @@ -23,5 +26,6 @@ interface NewAuthRequestService { suspend fun getAuthRequestUpdate( requestId: String, accessCode: String, + isSso: Boolean, ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceImpl.kt index c108373c6c..14cfb2b2cf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceImpl.kt @@ -1,14 +1,17 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service +import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAuthRequestsApi import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedAuthRequestsApi import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson +import com.x8bit.bitwarden.data.platform.util.asFailure /** * The default implementation of the [NewAuthRequestService]. */ class NewAuthRequestServiceImpl( + private val authenticatedAuthRequestsApi: AuthenticatedAuthRequestsApi, private val unauthenticatedAuthRequestsApi: UnauthenticatedAuthRequestsApi, ) : NewAuthRequestService { override suspend fun createAuthRequest( @@ -17,25 +20,54 @@ class NewAuthRequestServiceImpl( deviceId: String, accessCode: String, fingerprint: String, + authRequestType: AuthRequestTypeJson, ): Result = - unauthenticatedAuthRequestsApi.createAuthRequest( - deviceIdentifier = deviceId, - body = AuthRequestRequestJson( - email = email, - publicKey = publicKey, - deviceId = deviceId, - accessCode = accessCode, - fingerprint = fingerprint, - type = AuthRequestTypeJson.LOGIN_WITH_DEVICE, - ), - ) + when (authRequestType) { + AuthRequestTypeJson.LOGIN_WITH_DEVICE -> { + unauthenticatedAuthRequestsApi.createAuthRequest( + deviceIdentifier = deviceId, + body = AuthRequestRequestJson( + email = email, + publicKey = publicKey, + deviceId = deviceId, + accessCode = accessCode, + fingerprint = fingerprint, + type = authRequestType, + ), + ) + } + + AuthRequestTypeJson.UNLOCK -> { + UnsupportedOperationException("Unlock AuthRequestType is currently unsupported") + .asFailure() + } + + AuthRequestTypeJson.ADMIN_APPROVAL -> { + authenticatedAuthRequestsApi.createAdminAuthRequest( + deviceIdentifier = deviceId, + body = AuthRequestRequestJson( + email = email, + publicKey = publicKey, + deviceId = deviceId, + accessCode = accessCode, + fingerprint = fingerprint, + type = authRequestType, + ), + ) + } + } override suspend fun getAuthRequestUpdate( requestId: String, accessCode: String, + isSso: Boolean, ): Result = - unauthenticatedAuthRequestsApi.getAuthRequestUpdate( - requestId = requestId, - accessCode = accessCode, - ) + if (isSso) { + authenticatedAuthRequestsApi.getAuthRequest(requestId) + } else { + unauthenticatedAuthRequestsApi.getAuthRequestUpdate( + requestId = requestId, + accessCode = accessCode, + ) + } } 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 index 9c514a08eb..798e244339 100644 --- 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 @@ -2,6 +2,7 @@ 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.AuthRequestType 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 @@ -15,7 +16,10 @@ interface AuthRequestManager { /** * Creates a new authentication request and then continues to emit updates over time. */ - fun createAuthRequestWithUpdates(email: String): Flow + fun createAuthRequestWithUpdates( + email: String, + authRequestType: AuthRequestType, + ): Flow /** * Get an auth request by its [fingerprint] and emits updates for that request. 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 index d4e8e99a25..5f492dd723 100644 --- 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 @@ -2,15 +2,19 @@ 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.model.AuthRequestTypeJson 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.AuthRequestType 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.manager.util.isSso +import com.x8bit.bitwarden.data.auth.manager.util.toAuthRequestTypeJson import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -57,11 +61,17 @@ class AuthRequestManagerImpl( @Suppress("LongMethod") override fun createAuthRequestWithUpdates( email: String, + authRequestType: AuthRequestType, ): Flow = flow { - val initialResult = createNewAuthRequest(email).getOrNull() ?: run { - emit(CreateAuthRequestResult.Error) - return@flow - } + val initialResult = createNewAuthRequest( + email = email, + authRequestType = authRequestType.toAuthRequestTypeJson(), + ) + .getOrNull() + ?: run { + emit(CreateAuthRequestResult.Error) + return@flow + } val authRequestResponse = initialResult.authRequestResponse var authRequest = initialResult.authRequest emit(CreateAuthRequestResult.Update(authRequest)) @@ -73,6 +83,7 @@ class AuthRequestManagerImpl( .getAuthRequestUpdate( requestId = authRequest.id, accessCode = authRequestResponse.accessCode, + isSso = authRequestType.isSso, ) .map { request -> AuthRequest( @@ -310,6 +321,7 @@ class AuthRequestManagerImpl( */ private suspend fun createNewAuthRequest( email: String, + authRequestType: AuthRequestTypeJson, ): Result = authSdkSource .getNewAuthRequest(email) @@ -321,6 +333,7 @@ class AuthRequestManagerImpl( deviceId = authDiskSource.uniqueAppId, accessCode = authRequestResponse.accessCode, fingerprint = authRequestResponse.fingerprint, + authRequestType = authRequestType, ) .map { request -> AuthRequest( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestType.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestType.kt new file mode 100644 index 0000000000..cd765b53a1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/model/AuthRequestType.kt @@ -0,0 +1,10 @@ +package com.x8bit.bitwarden.data.auth.manager.model + +/** + * Represents the type of request to be made when making auth requests. + */ +enum class AuthRequestType { + OTHER_DEVICE, + SSO_OTHER_DEVICE, + SSO_ADMIN_APPROVAL, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensions.kt new file mode 100644 index 0000000000..5316da98d5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensions.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.data.auth.manager.util + +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestType + +/** + * Indicates if the given [AuthRequestType] uses SSO authentication. + */ +val AuthRequestType.isSso: Boolean + get() = when (this) { + AuthRequestType.OTHER_DEVICE -> false + AuthRequestType.SSO_OTHER_DEVICE, + AuthRequestType.SSO_ADMIN_APPROVAL, + -> true + } + +/** + * Converts the [AuthRequestType] to the appropriate [AuthRequestTypeJson]. + */ +fun AuthRequestType.toAuthRequestTypeJson(): AuthRequestTypeJson = + when (this) { + AuthRequestType.OTHER_DEVICE, + AuthRequestType.SSO_OTHER_DEVICE, + -> AuthRequestTypeJson.LOGIN_WITH_DEVICE + + AuthRequestType.SSO_ADMIN_APPROVAL -> AuthRequestTypeJson.ADMIN_APPROVAL + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index a7c65a62f6..6c383497e5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination import com.x8bit.bitwarden.ui.auth.feature.login.loginDestination import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint @@ -87,6 +88,7 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { onNavigateToLoginWithDevice = { emailAddress -> navController.navigateToLoginWithDevice( emailAddress = emailAddress, + loginType = LoginWithDeviceType.OTHER_DEVICE, ) }, onNavigateToTwoFactorLogin = { emailAddress, password -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt index ea6a4b09ce..c587c9dd80 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt @@ -4,20 +4,29 @@ import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.navArgument import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions private const val EMAIL_ADDRESS: String = "email_address" private const val LOGIN_WITH_DEVICE_PREFIX = "login_with_device" -private const val LOGIN_WITH_DEVICE_ROUTE = "$LOGIN_WITH_DEVICE_PREFIX/{$EMAIL_ADDRESS}" +private const val LOGIN_TYPE: String = "login_type" +private const val LOGIN_WITH_DEVICE_ROUTE = + "$LOGIN_WITH_DEVICE_PREFIX/{$EMAIL_ADDRESS}/{$LOGIN_TYPE}" /** * Class to retrieve login with device arguments from the [SavedStateHandle]. */ @OmitFromCoverage -data class LoginWithDeviceArgs(val emailAddress: String) { +data class LoginWithDeviceArgs( + val emailAddress: String, + val loginType: LoginWithDeviceType, +) { constructor(savedStateHandle: SavedStateHandle) : this( - checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String, + emailAddress = checkNotNull(savedStateHandle.get(EMAIL_ADDRESS)), + loginType = checkNotNull(savedStateHandle.get(LOGIN_TYPE)), ) } @@ -26,9 +35,13 @@ data class LoginWithDeviceArgs(val emailAddress: String) { */ fun NavController.navigateToLoginWithDevice( emailAddress: String, + loginType: LoginWithDeviceType, navOptions: NavOptions? = null, ) { - this.navigate("$LOGIN_WITH_DEVICE_PREFIX/$emailAddress", navOptions) + this.navigate( + route = "$LOGIN_WITH_DEVICE_PREFIX/$emailAddress/$loginType", + navOptions = navOptions, + ) } /** @@ -40,6 +53,10 @@ fun NavGraphBuilder.loginWithDeviceDestination( ) { composableWithSlideTransitions( route = LOGIN_WITH_DEVICE_ROUTE, + arguments = listOf( + navArgument(EMAIL_ADDRESS) { type = NavType.StringType }, + navArgument(LOGIN_TYPE) { type = NavType.EnumType(LoginWithDeviceType::class.java) }, + ), ) { LoginWithDeviceScreen( onNavigateBack = onNavigateBack, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt index ced30f4926..cbea5eb601 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt @@ -96,7 +96,7 @@ fun LoginWithDeviceScreen( .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenTopAppBar( - title = stringResource(id = R.string.log_in_with_device), + title = state.toolbarTitle(), scrollBehavior = scrollBehavior, navigationIcon = painterResource(id = R.drawable.ic_close), navigationIconContentDescription = stringResource(id = R.string.close), @@ -144,7 +144,7 @@ private fun LoginWithDeviceScreenContent( .verticalScroll(rememberScrollState()), ) { Text( - text = stringResource(id = R.string.log_in_initiated), + text = state.title(), textAlign = TextAlign.Start, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -156,7 +156,7 @@ private fun LoginWithDeviceScreenContent( Spacer(modifier = Modifier.height(24.dp)) Text( - text = stringResource(id = R.string.a_notification_has_been_sent_to_your_device), + text = state.subtitle(), textAlign = TextAlign.Start, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -167,9 +167,8 @@ private fun LoginWithDeviceScreenContent( Spacer(modifier = Modifier.height(16.dp)) - @Suppress("MaxLineLength") Text( - text = stringResource(id = R.string.please_make_sure_your_vault_is_unlocked_and_the_fingerprint_phrase_matches_on_the_other_device), + text = state.description(), textAlign = TextAlign.Start, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -204,33 +203,35 @@ private fun LoginWithDeviceScreenContent( .fillMaxWidth(), ) - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .defaultMinSize(minHeight = 40.dp) - .align(Alignment.Start), - ) { - if (state.isResendNotificationLoading) { - CircularProgressIndicator( - modifier = Modifier - .padding(horizontal = 64.dp) - .size(size = 16.dp), - ) - } else { - BitwardenClickableText( - modifier = Modifier.semantics { testTag = "ResendNotificationButton" }, - label = stringResource(id = R.string.resend_notification), - style = MaterialTheme.typography.labelLarge, - innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), - onClick = onResendNotificationClick, - ) + if (state.allowsResend) { + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .defaultMinSize(minHeight = 40.dp) + .align(Alignment.Start), + ) { + if (state.isResendNotificationLoading) { + CircularProgressIndicator( + modifier = Modifier + .padding(horizontal = 64.dp) + .size(size = 16.dp), + ) + } else { + BitwardenClickableText( + modifier = Modifier.semantics { testTag = "ResendNotificationButton" }, + label = stringResource(id = R.string.resend_notification), + style = MaterialTheme.typography.labelLarge, + innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), + onClick = onResendNotificationClick, + ) + } } } Spacer(modifier = Modifier.height(28.dp)) Text( - text = stringResource(id = R.string.need_another_option), + text = state.otherOptions(), textAlign = TextAlign.Start, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, 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 0283b4dd24..3d0fd51e7a 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 @@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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 +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.util.toAuthRequestType import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -35,12 +37,16 @@ class LoginWithDeviceViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: LoginWithDeviceState( - emailAddress = LoginWithDeviceArgs(savedStateHandle).emailAddress, - viewState = LoginWithDeviceState.ViewState.Loading, - dialogState = null, - loginData = null, - ), + ?: run { + val args = LoginWithDeviceArgs(savedStateHandle) + LoginWithDeviceState( + loginWithDeviceType = args.loginType, + emailAddress = args.emailAddress, + viewState = LoginWithDeviceState.ViewState.Loading, + dialogState = null, + loginData = null, + ) + }, ) { private var authJob: Job = Job().apply { complete() } @@ -104,6 +110,7 @@ class LoginWithDeviceViewModel @Inject constructor( mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( + loginWithDeviceType = it.loginWithDeviceType, fingerprintPhrase = "", isResendNotificationLoading = false, ), @@ -125,6 +132,7 @@ class LoginWithDeviceViewModel @Inject constructor( mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( + loginWithDeviceType = it.loginWithDeviceType, fingerprintPhrase = result.authRequest.fingerprint, isResendNotificationLoading = false, ), @@ -137,6 +145,7 @@ class LoginWithDeviceViewModel @Inject constructor( mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( + loginWithDeviceType = it.loginWithDeviceType, fingerprintPhrase = "", isResendNotificationLoading = false, ), @@ -152,6 +161,7 @@ class LoginWithDeviceViewModel @Inject constructor( mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( + loginWithDeviceType = it.loginWithDeviceType, fingerprintPhrase = "", isResendNotificationLoading = false, ), @@ -167,6 +177,7 @@ class LoginWithDeviceViewModel @Inject constructor( mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( + loginWithDeviceType = it.loginWithDeviceType, fingerprintPhrase = "", isResendNotificationLoading = false, ), @@ -256,16 +267,26 @@ class LoginWithDeviceViewModel @Inject constructor( ) } viewModelScope.launch { - val result = authRepository.login( - email = state.emailAddress, - requestId = loginData.requestId, - accessCode = loginData.accessCode, - asymmetricalKey = loginData.asymmetricalKey, - requestPrivateKey = loginData.privateKey, - masterPasswordHash = loginData.masterPasswordHash, - captchaToken = loginData.captchaToken, - ) - sendAction(LoginWithDeviceAction.Internal.ReceiveLoginResult(result)) + when (state.loginWithDeviceType) { + LoginWithDeviceType.OTHER_DEVICE -> { + val result = authRepository.login( + email = state.emailAddress, + requestId = loginData.requestId, + accessCode = loginData.accessCode, + asymmetricalKey = loginData.asymmetricalKey, + requestPrivateKey = loginData.privateKey, + masterPasswordHash = loginData.masterPasswordHash, + captchaToken = loginData.captchaToken, + ) + sendAction(LoginWithDeviceAction.Internal.ReceiveLoginResult(result)) + } + + LoginWithDeviceType.SSO_ADMIN_APPROVAL, + LoginWithDeviceType.SSO_OTHER_DEVICE, + -> { + sendEvent(LoginWithDeviceEvent.ShowToast("Not yet implemented!")) + } + } } } @@ -273,7 +294,10 @@ class LoginWithDeviceViewModel @Inject constructor( setIsResendNotificationLoading(isResend) authJob.cancel() authJob = authRepository - .createAuthRequestWithUpdates(email = state.emailAddress) + .createAuthRequestWithUpdates( + email = state.emailAddress, + authRequestType = state.loginWithDeviceType.toAuthRequestType(), + ) .map { LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) @@ -301,11 +325,25 @@ class LoginWithDeviceViewModel @Inject constructor( */ @Parcelize data class LoginWithDeviceState( + val loginWithDeviceType: LoginWithDeviceType, val emailAddress: String, val viewState: ViewState, val dialogState: DialogState?, val loginData: LoginData?, ) : Parcelable { + + /** + * The toolbar text for the UI. + */ + val toolbarTitle: Text + get() = when (loginWithDeviceType) { + LoginWithDeviceType.OTHER_DEVICE, + LoginWithDeviceType.SSO_OTHER_DEVICE, + -> R.string.log_in_with_device.asText() + + LoginWithDeviceType.SSO_ADMIN_APPROVAL -> R.string.log_in_initiated.asText() + } + /** * Represents the specific view states for the [LoginWithDeviceScreen]. */ @@ -322,12 +360,74 @@ data class LoginWithDeviceState( * Content state for the [LoginWithDeviceScreen] showing the actual content or items. * * @property fingerprintPhrase The fingerprint phrase to present to the user. + * @property isResendNotificationLoading Indicates if the resend loading spinner should be + * displayed. */ @Parcelize data class Content( val fingerprintPhrase: String, val isResendNotificationLoading: Boolean, - ) : ViewState() + private val loginWithDeviceType: LoginWithDeviceType, + ) : ViewState() { + /** + * The title text for the UI. + */ + val title: Text + get() = when (loginWithDeviceType) { + LoginWithDeviceType.OTHER_DEVICE, + LoginWithDeviceType.SSO_OTHER_DEVICE, + -> R.string.log_in_initiated.asText() + + LoginWithDeviceType.SSO_ADMIN_APPROVAL, + -> R.string.admin_approval_requested.asText() + } + + /** + * The subtitle text for the UI. + */ + val subtitle: Text + get() = when (loginWithDeviceType) { + LoginWithDeviceType.OTHER_DEVICE, + LoginWithDeviceType.SSO_OTHER_DEVICE, + -> R.string.a_notification_has_been_sent_to_your_device.asText() + + LoginWithDeviceType.SSO_ADMIN_APPROVAL, + -> R.string.your_request_has_been_sent_to_your_admin.asText() + } + + /** + * The description text for the UI. + */ + @Suppress("MaxLineLength") + val description: Text + get() = when (loginWithDeviceType) { + LoginWithDeviceType.OTHER_DEVICE, + LoginWithDeviceType.SSO_OTHER_DEVICE, + -> R.string.please_make_sure_your_vault_is_unlocked_and_the_fingerprint_phrase_matches_on_the_other_device.asText() + + LoginWithDeviceType.SSO_ADMIN_APPROVAL, + -> R.string.you_will_be_notified_once_approved.asText() + } + + /** + * The text to display indicating that there are other option for logging in. + */ + @Suppress("MaxLineLength") + val otherOptions: Text + get() = when (loginWithDeviceType) { + LoginWithDeviceType.OTHER_DEVICE, + LoginWithDeviceType.SSO_OTHER_DEVICE, + -> R.string.log_in_with_device_must_be_set_up_in_the_settings_of_the_bitwarden_app_need_another_option.asText() + + LoginWithDeviceType.SSO_ADMIN_APPROVAL -> R.string.trouble_logging_in.asText() + } + + /** + * Indicates if the resend button should be available. + */ + val allowsResend: Boolean + get() = loginWithDeviceType != LoginWithDeviceType.SSO_ADMIN_APPROVAL + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/model/LoginWithDeviceType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/model/LoginWithDeviceType.kt new file mode 100644 index 0000000000..12a504fb81 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/model/LoginWithDeviceType.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model + +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.LoginWithDeviceScreen + +/** + * Represents the different ways you may want to display the [LoginWithDeviceScreen]. + */ +enum class LoginWithDeviceType { + OTHER_DEVICE, + SSO_ADMIN_APPROVAL, + SSO_OTHER_DEVICE, +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensions.kt new file mode 100644 index 0000000000..a68ad1179e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensions.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.util + +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestType +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType + +/** + * Converts the [LoginWithDeviceType] to an appropriate [AuthRequestType]. + */ +fun LoginWithDeviceType.toAuthRequestType(): AuthRequestType = + when (this) { + LoginWithDeviceType.OTHER_DEVICE -> AuthRequestType.OTHER_DEVICE + LoginWithDeviceType.SSO_ADMIN_APPROVAL -> AuthRequestType.SSO_ADMIN_APPROVAL + LoginWithDeviceType.SSO_OTHER_DEVICE -> AuthRequestType.SSO_OTHER_DEVICE + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceTest.kt index bc63b9d17b..c5139f85fc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/NewAuthRequestServiceTest.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service +import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAuthRequestsApi import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedAuthRequestsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.util.asSuccess @@ -14,15 +16,97 @@ import java.time.ZonedDateTime class NewAuthRequestServiceTest : BaseServiceTest() { - private val authRequestsApi: UnauthenticatedAuthRequestsApi = retrofit.create() + private val authenticatedAuthRequestsApi: AuthenticatedAuthRequestsApi = retrofit.create() + private val unauthenticatedAuthRequestsApi: UnauthenticatedAuthRequestsApi = retrofit.create() private val service = NewAuthRequestServiceImpl( - unauthenticatedAuthRequestsApi = authRequestsApi, + authenticatedAuthRequestsApi = authenticatedAuthRequestsApi, + unauthenticatedAuthRequestsApi = unauthenticatedAuthRequestsApi, ) @Test - fun `createAuthRequest when request response is Failure should return Failure`() = runTest { - val response = MockResponse().setResponseCode(400) - server.enqueue(response) + fun `createAuthRequest when LOGIN_WITH_DEVICE and request is Failure should return Failure`() = + runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val deviceIdentifier = "4321" + val actual = service.createAuthRequest( + email = "test@gmail.com", + publicKey = "1234", + deviceId = deviceIdentifier, + accessCode = "accessCode", + fingerprint = "fingerprint", + authRequestType = AuthRequestTypeJson.LOGIN_WITH_DEVICE, + ) + + val request = server.takeRequest() + assertEquals(deviceIdentifier, request.getHeader("Device-Identifier")) + assertEquals("$urlPrefix/auth-requests", request.requestUrl.toString()) + assertTrue(actual.isFailure) + } + + @Test + fun `createAuthRequest when LOGIN_WITH_DEVICE and request is Success should return Success`() = + runTest { + val response = MockResponse().setBody(AUTH_REQUEST_RESPONSE_JSON).setResponseCode(200) + server.enqueue(response) + val deviceIdentifier = "4321" + val actual = service.createAuthRequest( + email = "test@gmail.com", + publicKey = "1234", + deviceId = deviceIdentifier, + accessCode = "accessCode", + fingerprint = "fingerprint", + authRequestType = AuthRequestTypeJson.LOGIN_WITH_DEVICE, + ) + val request = server.takeRequest() + assertEquals(deviceIdentifier, request.getHeader("Device-Identifier")) + assertEquals("$urlPrefix/auth-requests", request.requestUrl.toString()) + assertEquals(AUTH_REQUEST_RESPONSE.asSuccess(), actual) + } + + @Test + fun `createAuthRequest when ADMIN_APPROVAL and request is Failure should return Failure`() = + runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val deviceIdentifier = "4321" + val actual = service.createAuthRequest( + email = "test@gmail.com", + publicKey = "1234", + deviceId = deviceIdentifier, + accessCode = "accessCode", + fingerprint = "fingerprint", + authRequestType = AuthRequestTypeJson.ADMIN_APPROVAL, + ) + + val request = server.takeRequest() + assertEquals(deviceIdentifier, request.getHeader("Device-Identifier")) + assertEquals("$urlPrefix/auth-requests/admin-request", request.requestUrl.toString()) + assertTrue(actual.isFailure) + } + + @Test + fun `createAuthRequest when ADMIN_APPROVAL and request is Success should return Success`() = + runTest { + val response = MockResponse().setBody(AUTH_REQUEST_RESPONSE_JSON).setResponseCode(200) + server.enqueue(response) + val deviceIdentifier = "4321" + val actual = service.createAuthRequest( + email = "test@gmail.com", + publicKey = "1234", + deviceId = deviceIdentifier, + accessCode = "accessCode", + fingerprint = "fingerprint", + authRequestType = AuthRequestTypeJson.ADMIN_APPROVAL, + ) + val request = server.takeRequest() + assertEquals(deviceIdentifier, request.getHeader("Device-Identifier")) + assertEquals("$urlPrefix/auth-requests/admin-request", request.requestUrl.toString()) + assertEquals(AUTH_REQUEST_RESPONSE.asSuccess(), actual) + } + + @Test + fun `createAuthRequest when UNLOCK should return Failure`() = runTest { val deviceIdentifier = "4321" val actual = service.createAuthRequest( email = "test@gmail.com", @@ -30,48 +114,82 @@ class NewAuthRequestServiceTest : BaseServiceTest() { deviceId = deviceIdentifier, accessCode = "accessCode", fingerprint = "fingerprint", - ) - assertEquals(deviceIdentifier, server.takeRequest().getHeader("Device-Identifier")) - assertTrue(actual.isFailure) - } - - @Test - fun `createAuthRequest when request response is Success should return Success`() = runTest { - val response = MockResponse().setBody(AUTH_REQUEST_RESPONSE_JSON).setResponseCode(200) - server.enqueue(response) - val deviceIdentifier = "4321" - val actual = service.createAuthRequest( - email = "test@gmail.com", - publicKey = "1234", - deviceId = deviceIdentifier, - accessCode = "accessCode", - fingerprint = "fingerprint", - ) - assertEquals(deviceIdentifier, server.takeRequest().getHeader("Device-Identifier")) - assertEquals(AUTH_REQUEST_RESPONSE.asSuccess(), actual) - } - - @Test - fun `getAuthRequestUpdate when request response is Failure should return Failure`() = runTest { - val response = MockResponse().setResponseCode(400) - server.enqueue(response) - val actual = service.getAuthRequestUpdate( - requestId = "1", - accessCode = "accessCode", + authRequestType = AuthRequestTypeJson.UNLOCK, ) assertTrue(actual.isFailure) } @Test - fun `getAuthRequestUpdate when request response is Success should return Success`() = runTest { - val response = MockResponse().setBody(AUTH_REQUEST_RESPONSE_JSON).setResponseCode(200) - server.enqueue(response) - val actual = service.getAuthRequestUpdate( - requestId = "1", - accessCode = "accessCode", - ) - assertEquals(AUTH_REQUEST_RESPONSE.asSuccess(), actual) - } + fun `getAuthRequestUpdate when not SSO and response is Failure should return Failure`() = + runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val requestId = "1" + val accessCode = "accessCode" + val actual = service.getAuthRequestUpdate( + requestId = requestId, + accessCode = accessCode, + isSso = false, + ) + val request = server.takeRequest() + assertEquals( + "$urlPrefix/auth-requests/$requestId/response?code=$accessCode", + request.requestUrl.toString(), + ) + assertTrue(actual.isFailure) + } + + @Test + fun `getAuthRequestUpdate when not SSO and response is Success should return Success`() = + runTest { + val response = MockResponse().setBody(AUTH_REQUEST_RESPONSE_JSON).setResponseCode(200) + server.enqueue(response) + val requestId = "1" + val accessCode = "accessCode" + val actual = service.getAuthRequestUpdate( + requestId = requestId, + accessCode = accessCode, + isSso = false, + ) + val request = server.takeRequest() + assertEquals( + "$urlPrefix/auth-requests/$requestId/response?code=$accessCode", + request.requestUrl.toString(), + ) + assertEquals(AUTH_REQUEST_RESPONSE.asSuccess(), actual) + } + + @Test + fun `getAuthRequestUpdate when SSO and response is Failure should return Failure`() = + runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val requestId = "1" + val actual = service.getAuthRequestUpdate( + requestId = requestId, + accessCode = "accessCode", + isSso = true, + ) + val request = server.takeRequest() + assertEquals("$urlPrefix/auth-requests/$requestId", request.requestUrl.toString()) + assertTrue(actual.isFailure) + } + + @Test + fun `getAuthRequestUpdate when SSO and response is Success should return Success`() = + runTest { + val response = MockResponse().setBody(AUTH_REQUEST_RESPONSE_JSON).setResponseCode(200) + server.enqueue(response) + val requestId = "1" + val actual = service.getAuthRequestUpdate( + requestId = requestId, + accessCode = "accessCode", + isSso = true, + ) + val request = server.takeRequest() + assertEquals("$urlPrefix/auth-requests/$requestId", request.requestUrl.toString()) + assertEquals(AUTH_REQUEST_RESPONSE.asSuccess(), actual) + } } private const val AUTH_REQUEST_RESPONSE_JSON = """ 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 index 9855b9123d..f4086f8673 100644 --- 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 @@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson 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.AuthRequestTypeJson 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 @@ -13,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestSe 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.AuthRequestType 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 @@ -74,13 +76,19 @@ class AuthRequestManagerTest { deviceId = fakeAuthDiskSource.uniqueAppId, accessCode = authRequestResponse.accessCode, fingerprint = authRequestResponse.fingerprint, + authRequestType = AuthRequestTypeJson.LOGIN_WITH_DEVICE, ) } returns Throwable("Fail").asFailure() - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Error, awaitItem()) - awaitComplete() - } + repository + .createAuthRequestWithUpdates( + email = email, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) + .test { + assertEquals(CreateAuthRequestResult.Error, awaitItem()) + awaitComplete() + } } @Suppress("MaxLineLength") @@ -127,30 +135,37 @@ class AuthRequestManagerTest { deviceId = fakeAuthDiskSource.uniqueAppId, accessCode = authRequestResponse.accessCode, fingerprint = authRequestResponse.fingerprint, + authRequestType = AuthRequestTypeJson.LOGIN_WITH_DEVICE, ) } returns authRequestResponseJson.asSuccess() coEvery { newAuthRequestService.getAuthRequestUpdate( requestId = authRequest.id, accessCode = authRequestResponse.accessCode, + isSso = false, ) } 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(), + repository + .createAuthRequestWithUpdates( + email = email, + authRequestType = AuthRequestType.OTHER_DEVICE, ) - awaitComplete() - } + .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") @@ -197,20 +212,27 @@ class AuthRequestManagerTest { deviceId = fakeAuthDiskSource.uniqueAppId, accessCode = authRequestResponse.accessCode, fingerprint = authRequestResponse.fingerprint, + authRequestType = AuthRequestTypeJson.LOGIN_WITH_DEVICE, ) } returns authRequestResponseJson.asSuccess() coEvery { newAuthRequestService.getAuthRequestUpdate( requestId = authRequest.id, accessCode = authRequestResponse.accessCode, + isSso = false, ) } returns updatedAuthRequestResponseJson.asSuccess() - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) - assertEquals(CreateAuthRequestResult.Declined, awaitItem()) - awaitComplete() - } + repository + .createAuthRequestWithUpdates( + email = email, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) + .test { + assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) + assertEquals(CreateAuthRequestResult.Declined, awaitItem()) + awaitComplete() + } } @Suppress("MaxLineLength") @@ -257,20 +279,27 @@ class AuthRequestManagerTest { deviceId = fakeAuthDiskSource.uniqueAppId, accessCode = authRequestResponse.accessCode, fingerprint = authRequestResponse.fingerprint, + authRequestType = AuthRequestTypeJson.ADMIN_APPROVAL, ) } returns authRequestResponseJson.asSuccess() coEvery { newAuthRequestService.getAuthRequestUpdate( requestId = authRequest.id, accessCode = authRequestResponse.accessCode, + isSso = true, ) } returns updatedAuthRequestResponseJson.asSuccess() - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) - assertEquals(CreateAuthRequestResult.Expired, awaitItem()) - awaitComplete() - } + repository + .createAuthRequestWithUpdates( + email = email, + authRequestType = AuthRequestType.SSO_ADMIN_APPROVAL, + ) + .test { + assertEquals(CreateAuthRequestResult.Update(authRequest), awaitItem()) + assertEquals(CreateAuthRequestResult.Expired, awaitItem()) + awaitComplete() + } } @Suppress("MaxLineLength") @@ -282,10 +311,15 @@ class AuthRequestManagerTest { authSdkSource.getNewAuthRequest(email = email) } returns Throwable("Fail").asFailure() - repository.createAuthRequestWithUpdates(email = email).test { - assertEquals(CreateAuthRequestResult.Error, awaitItem()) - awaitComplete() - } + repository + .createAuthRequestWithUpdates( + email = email, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) + .test { + assertEquals(CreateAuthRequestResult.Error, awaitItem()) + awaitComplete() + } } @Suppress("MaxLineLength") diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensionsTest.kt new file mode 100644 index 0000000000..74590f1e99 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/manager/util/AuthRequestTypeExtensionsTest.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.auth.manager.util + +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AuthRequestTypeExtensionsTest { + @Test + fun `isSso should return the correct value for each type`() { + mapOf( + AuthRequestType.OTHER_DEVICE to false, + AuthRequestType.SSO_OTHER_DEVICE to true, + AuthRequestType.SSO_ADMIN_APPROVAL to true, + ) + .forEach { (type, expected) -> + assertEquals(expected, type.isSso) + } + } + + @Test + fun `toAuthRequestTypeJson should return the correct value for each type`() { + mapOf( + AuthRequestType.OTHER_DEVICE to AuthRequestTypeJson.LOGIN_WITH_DEVICE, + AuthRequestType.SSO_OTHER_DEVICE to AuthRequestTypeJson.LOGIN_WITH_DEVICE, + AuthRequestType.SSO_ADMIN_APPROVAL to AuthRequestTypeJson.ADMIN_APPROVAL, + ) + .forEach { (type, expected) -> + assertEquals(expected, type.toAuthRequestTypeJson()) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt index 33293b3eef..659c9f9823 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt @@ -17,6 +17,8 @@ abstract class BaseServiceTest { protected val server = MockWebServer().apply { start() } + protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}" + protected val retrofit: Retrofit = Retrofit.Builder() .baseUrl(server.url("/").toString()) .addCallAdapterFactory(ResultCallAdapterFactory()) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt index c134579430..2ed2871712 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -176,7 +177,9 @@ private val DEFAULT_STATE = LoginWithDeviceState( viewState = LoginWithDeviceState.ViewState.Content( fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate", isResendNotificationLoading = false, + loginWithDeviceType = LoginWithDeviceType.OTHER_DEVICE, ), dialogState = null, loginData = null, + loginWithDeviceType = LoginWithDeviceType.OTHER_DEVICE, ) 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 a141b51039..36f7e6721c 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 @@ -5,11 +5,13 @@ 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.AuthRequestType 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.LoginResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.awaits @@ -31,7 +33,7 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { bufferedMutableSharedFlow() private val authRepository = mockk { coEvery { - createAuthRequestWithUpdates(EMAIL) + createAuthRequestWithUpdates(email = EMAIL, authRequestType = any()) } returns mutableCreateAuthRequestWithUpdatesFlow coEvery { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow } @@ -44,7 +46,10 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { viewModel.stateFlow.value, ) coVerify(exactly = 1) { - authRepository.createAuthRequestWithUpdates(EMAIL) + authRepository.createAuthRequestWithUpdates( + email = EMAIL, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) } } @@ -53,12 +58,18 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { val newEmail = "newEmail@gmail.com" val state = DEFAULT_STATE.copy(emailAddress = newEmail) coEvery { - authRepository.createAuthRequestWithUpdates(newEmail) + authRepository.createAuthRequestWithUpdates( + email = newEmail, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) } returns mutableCreateAuthRequestWithUpdatesFlow val viewModel = createViewModel(state = state) assertEquals(state, viewModel.stateFlow.value) coVerify(exactly = 1) { - authRepository.createAuthRequestWithUpdates(newEmail) + authRepository.createAuthRequestWithUpdates( + email = newEmail, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) } } @@ -100,7 +111,10 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { viewModel.stateFlow.value, ) verify(exactly = 2) { - authRepository.createAuthRequestWithUpdates(EMAIL) + authRepository.createAuthRequestWithUpdates( + email = EMAIL, + authRequestType = AuthRequestType.OTHER_DEVICE, + ) } } @@ -191,6 +205,43 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } } + @Test + fun `on createAuthRequestWithUpdates Success with SSO_ADMIN_APPROVAL should emit toast`() = + runTest { + val initialViewState = DEFAULT_CONTENT_VIEW_STATE.copy( + loginWithDeviceType = LoginWithDeviceType.SSO_ADMIN_APPROVAL, + ) + val initialState = DEFAULT_STATE.copy( + viewState = initialViewState, + loginWithDeviceType = LoginWithDeviceType.SSO_ADMIN_APPROVAL, + ) + val viewModel = createViewModel(initialState) + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(initialState, stateFlow.awaitItem()) + mutableCreateAuthRequestWithUpdatesFlow.tryEmit( + CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE), + ) + assertEquals( + initialState.copy( + viewState = initialViewState.copy( + fingerprintPhrase = "", + ), + dialogState = LoginWithDeviceState.DialogState.Loading( + message = R.string.logging_in.asText(), + ), + loginData = DEFAULT_LOGIN_DATA, + ), + stateFlow.awaitItem(), + ) + + assertEquals( + LoginWithDeviceEvent.ShowToast("Not yet implemented!"), + eventFlow.awaitItem(), + ) + } + } + @Suppress("MaxLineLength") @Test fun `on createAuthRequestWithUpdates Success and login two factor required should emit NavigateToTwoFactorLogin`() = @@ -419,6 +470,7 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle().apply { set("state", state) set("email_address", state?.emailAddress ?: EMAIL) + set("login_type", state?.loginWithDeviceType ?: LoginWithDeviceType.OTHER_DEVICE) }, ) } @@ -429,6 +481,7 @@ private const val FINGERPRINT = "fingerprint" private val DEFAULT_CONTENT_VIEW_STATE = LoginWithDeviceState.ViewState.Content( fingerprintPhrase = FINGERPRINT, isResendNotificationLoading = false, + loginWithDeviceType = LoginWithDeviceType.OTHER_DEVICE, ) private val DEFAULT_STATE = LoginWithDeviceState( @@ -436,6 +489,7 @@ private val DEFAULT_STATE = LoginWithDeviceState( viewState = DEFAULT_CONTENT_VIEW_STATE, dialogState = null, loginData = null, + loginWithDeviceType = LoginWithDeviceType.OTHER_DEVICE, ) private val AUTH_REQUEST = AuthRequest( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensionsTest.kt new file mode 100644 index 0000000000..2c67dbe28f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/util/LoginWithDeviceTypeExtensionsTest.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.util + +import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestType +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LoginWithDeviceTypeExtensionsTest { + @Test + fun `toAuthRequestTypeJson should return the correct value for each type`() { + mapOf( + LoginWithDeviceType.OTHER_DEVICE to AuthRequestType.OTHER_DEVICE, + LoginWithDeviceType.SSO_OTHER_DEVICE to AuthRequestType.SSO_OTHER_DEVICE, + LoginWithDeviceType.SSO_ADMIN_APPROVAL to AuthRequestType.SSO_ADMIN_APPROVAL, + ) + .forEach { (type, expected) -> + assertEquals(expected, type.toAuthRequestType()) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseViewModelTest.kt index 5dd7f31058..1073b00087 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseViewModelTest.kt @@ -1,9 +1,28 @@ package com.x8bit.bitwarden.ui.platform.base +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.TurbineContext +import app.cash.turbine.turbineScope +import kotlinx.coroutines.CoroutineScope import org.junit.jupiter.api.extension.RegisterExtension abstract class BaseViewModelTest { @Suppress("unused") @RegisterExtension protected open val mainDispatcherExtension = MainDispatcherExtension() + + protected suspend fun > T.stateEventFlow( + backgroundScope: CoroutineScope, + validate: suspend TurbineContext.( + stateFlow: ReceiveTurbine, + eventFlow: ReceiveTurbine, + ) -> Unit, + ) { + turbineScope { + this.validate( + this@stateEventFlow.stateFlow.testIn(backgroundScope), + this@stateEventFlow.eventFlow.testIn(backgroundScope), + ) + } + } }