Update LoginWithDeviceScreen to support Admin Approval type (#1175)

This commit is contained in:
David Perez 2024-04-01 10:34:05 -05:00 committed by Álison Fernandes
parent 1150f01666
commit b2005f01c1
22 changed files with 675 additions and 144 deletions

View file

@ -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<AuthRequestsResponseJson.AuthRequest>
/**
* Updates an authentication request.
*/

View file

@ -85,6 +85,7 @@ object AuthNetworkModule {
fun providesNewAuthRequestService(
retrofits: Retrofits,
): NewAuthRequestService = NewAuthRequestServiceImpl(
authenticatedAuthRequestsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedAuthRequestsApi = retrofits.unauthenticatedApiRetrofit.create(),
)

View file

@ -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<AuthRequestsResponseJson.AuthRequest>
/**
@ -23,5 +26,6 @@ interface NewAuthRequestService {
suspend fun getAuthRequestUpdate(
requestId: String,
accessCode: String,
isSso: Boolean,
): Result<AuthRequestsResponseJson.AuthRequest>
}

View file

@ -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<AuthRequestsResponseJson.AuthRequest> =
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<AuthRequestsResponseJson.AuthRequest> =
unauthenticatedAuthRequestsApi.getAuthRequestUpdate(
requestId = requestId,
accessCode = accessCode,
)
if (isSso) {
authenticatedAuthRequestsApi.getAuthRequest(requestId)
} else {
unauthenticatedAuthRequestsApi.getAuthRequestUpdate(
requestId = requestId,
accessCode = accessCode,
)
}
}

View file

@ -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<CreateAuthRequestResult>
fun createAuthRequestWithUpdates(
email: String,
authRequestType: AuthRequestType,
): Flow<CreateAuthRequestResult>
/**
* Get an auth request by its [fingerprint] and emits updates for that request.

View file

@ -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<CreateAuthRequestResult> = 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<NewAuthRequestData> =
authSdkSource
.getNewAuthRequest(email)
@ -321,6 +333,7 @@ class AuthRequestManagerImpl(
deviceId = authDiskSource.uniqueAppId,
accessCode = authRequestResponse.accessCode,
fingerprint = authRequestResponse.fingerprint,
authRequestType = authRequestType,
)
.map { request ->
AuthRequest(

View file

@ -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,
}

View file

@ -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
}

View file

@ -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 ->

View file

@ -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<String>(EMAIL_ADDRESS)),
loginType = checkNotNull(savedStateHandle.get<LoginWithDeviceType>(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,

View file

@ -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,

View file

@ -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<LoginWithDeviceState, LoginWithDeviceEvent, LoginWithDeviceAction>(
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
}
}
/**

View file

@ -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,
}

View file

@ -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
}

View file

@ -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 = """

View file

@ -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")

View file

@ -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())
}
}
}

View file

@ -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())

View file

@ -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,
)

View file

@ -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<CaptchaCallbackTokenResult>()
private val authRepository = mockk<AuthRepository> {
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(

View file

@ -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())
}
}
}

View file

@ -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 <S : Any, E : Any, T : BaseViewModel<S, E, *>> T.stateEventFlow(
backgroundScope: CoroutineScope,
validate: suspend TurbineContext.(
stateFlow: ReceiveTurbine<S>,
eventFlow: ReceiveTurbine<E>,
) -> Unit,
) {
turbineScope {
this.validate(
this@stateEventFlow.stateFlow.testIn(backgroundScope),
this@stateEventFlow.eventFlow.testIn(backgroundScope),
)
}
}
}