mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-1565: Approve and decline login requests (#818)
This commit is contained in:
parent
3be37766e2
commit
a187fbb0d1
19 changed files with 726 additions and 71 deletions
|
@ -1,10 +1,13 @@
|
||||||
package com.x8bit.bitwarden.data.auth.datasource.network.api
|
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.AuthRequestRequestJson
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines raw calls under the /auth-requests API.
|
* Defines raw calls under the /auth-requests API.
|
||||||
|
@ -19,6 +22,15 @@ interface AuthRequestsApi {
|
||||||
@Body body: AuthRequestRequestJson,
|
@Body body: AuthRequestRequestJson,
|
||||||
): Result<AuthRequestsResponseJson.AuthRequest>
|
): Result<AuthRequestsResponseJson.AuthRequest>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an authentication request.
|
||||||
|
*/
|
||||||
|
@PUT("/auth-requests/{id}")
|
||||||
|
suspend fun updateAuthRequest(
|
||||||
|
@Path("id") userId: String,
|
||||||
|
@Body body: AuthRequestUpdateRequestJson,
|
||||||
|
): Result<AuthRequestsResponseJson.AuthRequest>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a list of auth requests for this device.
|
* Gets a list of auth requests for this device.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for updating an auth request.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AuthRequestUpdateRequestJson(
|
||||||
|
@SerialName("key")
|
||||||
|
val key: String,
|
||||||
|
|
||||||
|
@SerialName("masterPasswordHash")
|
||||||
|
val masterPasswordHash: String?,
|
||||||
|
|
||||||
|
@SerialName("deviceIdentifier")
|
||||||
|
val deviceId: String,
|
||||||
|
|
||||||
|
@SerialName("requestApproved")
|
||||||
|
val isApproved: Boolean,
|
||||||
|
)
|
|
@ -10,4 +10,15 @@ interface AuthRequestsService {
|
||||||
* Gets the list of auth requests for the current user.
|
* Gets the list of auth requests for the current user.
|
||||||
*/
|
*/
|
||||||
suspend fun getAuthRequests(): Result<AuthRequestsResponseJson>
|
suspend fun getAuthRequests(): Result<AuthRequestsResponseJson>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an approval request.
|
||||||
|
*/
|
||||||
|
suspend fun updateAuthRequest(
|
||||||
|
requestId: String,
|
||||||
|
key: String,
|
||||||
|
masterPasswordHash: String?,
|
||||||
|
deviceId: String,
|
||||||
|
isApproved: Boolean,
|
||||||
|
): Result<AuthRequestsResponseJson.AuthRequest>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.x8bit.bitwarden.data.auth.datasource.network.service
|
package com.x8bit.bitwarden.data.auth.datasource.network.service
|
||||||
|
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
|
||||||
|
|
||||||
class AuthRequestsServiceImpl(
|
class AuthRequestsServiceImpl(
|
||||||
|
@ -8,4 +9,21 @@ class AuthRequestsServiceImpl(
|
||||||
) : AuthRequestsService {
|
) : AuthRequestsService {
|
||||||
override suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> =
|
override suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> =
|
||||||
authRequestsApi.getAuthRequests()
|
authRequestsApi.getAuthRequests()
|
||||||
|
|
||||||
|
override suspend fun updateAuthRequest(
|
||||||
|
requestId: String,
|
||||||
|
key: String,
|
||||||
|
masterPasswordHash: String?,
|
||||||
|
deviceId: String,
|
||||||
|
isApproved: Boolean,
|
||||||
|
): Result<AuthRequestsResponseJson.AuthRequest> =
|
||||||
|
authRequestsApi.updateAuthRequest(
|
||||||
|
userId = requestId,
|
||||||
|
body = AuthRequestUpdateRequestJson(
|
||||||
|
key = key,
|
||||||
|
masterPasswordHash = masterPasswordHash,
|
||||||
|
deviceId = deviceId,
|
||||||
|
isApproved = isApproved,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -196,6 +196,17 @@ interface AuthRepository : AuthenticatorProvider {
|
||||||
*/
|
*/
|
||||||
suspend fun getAuthRequests(): AuthRequestsResult
|
suspend fun getAuthRequests(): AuthRequestsResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approves or declines the request corresponding to this [requestId] based on [publicKey]
|
||||||
|
* according to [isApproved].
|
||||||
|
*/
|
||||||
|
suspend fun updateAuthRequest(
|
||||||
|
requestId: String,
|
||||||
|
masterPasswordHash: String?,
|
||||||
|
publicKey: String,
|
||||||
|
isApproved: Boolean,
|
||||||
|
): AuthRequestResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a [Boolean] indicating whether this is a known device.
|
* Get a [Boolean] indicating whether this is a known device.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||||
import com.x8bit.bitwarden.data.platform.util.flatMap
|
import com.x8bit.bitwarden.data.platform.util.flatMap
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -76,7 +77,7 @@ import javax.inject.Singleton
|
||||||
/**
|
/**
|
||||||
* Default implementation of [AuthRepository].
|
* Default implementation of [AuthRepository].
|
||||||
*/
|
*/
|
||||||
@Suppress("LongParameterList", "TooManyFunctions")
|
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthRepositoryImpl(
|
class AuthRepositoryImpl(
|
||||||
private val accountsService: AccountsService,
|
private val accountsService: AccountsService,
|
||||||
|
@ -87,6 +88,7 @@ class AuthRepositoryImpl(
|
||||||
private val newAuthRequestService: NewAuthRequestService,
|
private val newAuthRequestService: NewAuthRequestService,
|
||||||
private val organizationService: OrganizationService,
|
private val organizationService: OrganizationService,
|
||||||
private val authSdkSource: AuthSdkSource,
|
private val authSdkSource: AuthSdkSource,
|
||||||
|
private val vaultSdkSource: VaultSdkSource,
|
||||||
private val authDiskSource: AuthDiskSource,
|
private val authDiskSource: AuthDiskSource,
|
||||||
private val environmentRepository: EnvironmentRepository,
|
private val environmentRepository: EnvironmentRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
|
@ -693,6 +695,51 @@ class AuthRepositoryImpl(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override suspend fun updateAuthRequest(
|
||||||
|
requestId: String,
|
||||||
|
masterPasswordHash: String?,
|
||||||
|
publicKey: String,
|
||||||
|
isApproved: Boolean,
|
||||||
|
): AuthRequestResult {
|
||||||
|
val userId = activeUserId ?: return AuthRequestResult.Error
|
||||||
|
return vaultSdkSource
|
||||||
|
.getAuthRequestKey(
|
||||||
|
publicKey = publicKey,
|
||||||
|
userId = userId,
|
||||||
|
)
|
||||||
|
.flatMap {
|
||||||
|
authRequestsService
|
||||||
|
.updateAuthRequest(
|
||||||
|
requestId = requestId,
|
||||||
|
key = it,
|
||||||
|
deviceId = authDiskSource.uniqueAppId,
|
||||||
|
masterPasswordHash = masterPasswordHash,
|
||||||
|
isApproved = isApproved,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map { request ->
|
||||||
|
AuthRequestResult.Success(
|
||||||
|
authRequest = AuthRequest(
|
||||||
|
id = request.id,
|
||||||
|
publicKey = request.publicKey,
|
||||||
|
platform = request.platform,
|
||||||
|
ipAddress = request.ipAddress,
|
||||||
|
key = request.key,
|
||||||
|
masterPasswordHash = request.masterPasswordHash,
|
||||||
|
creationDate = request.creationDate,
|
||||||
|
responseDate = request.responseDate,
|
||||||
|
requestApproved = request.requestApproved ?: false,
|
||||||
|
originUrl = request.originUrl,
|
||||||
|
fingerprint = "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fold(
|
||||||
|
onFailure = { AuthRequestResult.Error },
|
||||||
|
onSuccess = { it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
|
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
|
||||||
devicesService
|
devicesService
|
||||||
.getIsKnownDevice(
|
.getIsKnownDevice(
|
||||||
|
|
|
@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
|
||||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
@ -41,6 +42,7 @@ object AuthRepositoryModule {
|
||||||
newAuthRequestService: NewAuthRequestService,
|
newAuthRequestService: NewAuthRequestService,
|
||||||
organizationService: OrganizationService,
|
organizationService: OrganizationService,
|
||||||
authSdkSource: AuthSdkSource,
|
authSdkSource: AuthSdkSource,
|
||||||
|
vaultSdkSource: VaultSdkSource,
|
||||||
authDiskSource: AuthDiskSource,
|
authDiskSource: AuthDiskSource,
|
||||||
dispatchers: DispatcherManager,
|
dispatchers: DispatcherManager,
|
||||||
environmentRepository: EnvironmentRepository,
|
environmentRepository: EnvironmentRepository,
|
||||||
|
@ -55,6 +57,7 @@ object AuthRepositoryModule {
|
||||||
newAuthRequestService = newAuthRequestService,
|
newAuthRequestService = newAuthRequestService,
|
||||||
organizationService = organizationService,
|
organizationService = organizationService,
|
||||||
authSdkSource = authSdkSource,
|
authSdkSource = authSdkSource,
|
||||||
|
vaultSdkSource = vaultSdkSource,
|
||||||
authDiskSource = authDiskSource,
|
authDiskSource = authDiskSource,
|
||||||
haveIBeenPwnedService = haveIBeenPwnedService,
|
haveIBeenPwnedService = haveIBeenPwnedService,
|
||||||
dispatcherManager = dispatchers,
|
dispatcherManager = dispatchers,
|
||||||
|
|
|
@ -59,6 +59,14 @@ interface VaultSdkSource {
|
||||||
encryptedPin: String,
|
encryptedPin: String,
|
||||||
): Result<String>
|
): Result<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the key for an auth request that is required to approve or decline it.
|
||||||
|
*/
|
||||||
|
suspend fun getAuthRequestKey(
|
||||||
|
publicKey: String,
|
||||||
|
userId: String,
|
||||||
|
): Result<String>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the user's encryption key, which can be used to later unlock their vault via a call to
|
* Gets the user's encryption key, which can be used to later unlock their vault via a call to
|
||||||
* [initializeCrypto] with [InitUserCryptoMethod.DecryptedKey].
|
* [initializeCrypto] with [InitUserCryptoMethod.DecryptedKey].
|
||||||
|
|
|
@ -56,6 +56,16 @@ class VaultSdkSourceImpl(
|
||||||
.derivePinUserKey(encryptedPin = encryptedPin)
|
.derivePinUserKey(encryptedPin = encryptedPin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getAuthRequestKey(
|
||||||
|
publicKey: String,
|
||||||
|
userId: String,
|
||||||
|
): Result<String> =
|
||||||
|
runCatching {
|
||||||
|
getClient(userId = userId)
|
||||||
|
.auth()
|
||||||
|
.approveAuthRequest(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getUserEncryptionKey(
|
override suspend fun getUserEncryptionKey(
|
||||||
userId: String,
|
userId: String,
|
||||||
): Result<String> =
|
): Result<String> =
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.x8bit.bitwarden.ui.platform.base.util
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a side effect to observe lifecycle events.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LivecycleEventEffect(
|
||||||
|
onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit,
|
||||||
|
) {
|
||||||
|
val eventHandler = rememberUpdatedState(onEvent)
|
||||||
|
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner.value) {
|
||||||
|
val lifecycle = lifecycleOwner.value.lifecycle
|
||||||
|
val observer = LifecycleEventObserver { owner, event ->
|
||||||
|
eventHandler.value(owner, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -29,6 +29,9 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
||||||
|
@ -61,6 +64,20 @@ fun LoginApprovalScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BitwardenBasicDialog(
|
||||||
|
visibilityState = if (state.shouldShowErrorDialog) {
|
||||||
|
BasicDialogState.Shown(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = R.string.generic_error_message.asText(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BasicDialogState.Hidden
|
||||||
|
},
|
||||||
|
onDismissRequest = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginap
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
|
@ -29,6 +30,10 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
initialState = savedStateHandle[KEY_STATE]
|
initialState = savedStateHandle[KEY_STATE]
|
||||||
?: LoginApprovalState(
|
?: LoginApprovalState(
|
||||||
fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint,
|
fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint,
|
||||||
|
masterPasswordHash = null,
|
||||||
|
publicKey = "",
|
||||||
|
requestId = "",
|
||||||
|
shouldShowErrorDialog = false,
|
||||||
viewState = LoginApprovalState.ViewState.Loading,
|
viewState = LoginApprovalState.ViewState.Loading,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
@ -52,16 +57,35 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked()
|
LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked()
|
||||||
LoginApprovalAction.CloseClick -> handleCloseClicked()
|
LoginApprovalAction.CloseClick -> handleCloseClicked()
|
||||||
LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked()
|
LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked()
|
||||||
|
LoginApprovalAction.ErrorDialogDismiss -> handleErrorDialogDismissed()
|
||||||
|
|
||||||
|
is LoginApprovalAction.Internal.ApproveRequestResultReceive -> {
|
||||||
|
handleApproveRequestResultReceived(action)
|
||||||
|
}
|
||||||
|
|
||||||
is LoginApprovalAction.Internal.AuthRequestResultReceive -> {
|
is LoginApprovalAction.Internal.AuthRequestResultReceive -> {
|
||||||
handleAuthRequestResultReceived(action)
|
handleAuthRequestResultReceived(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is LoginApprovalAction.Internal.DeclineRequestResultReceive -> {
|
||||||
|
handleDeclineRequestResultReceived(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleApproveRequestClicked() {
|
private fun handleApproveRequestClicked() {
|
||||||
// TODO BIT-1565 implement approve login request
|
viewModelScope.launch {
|
||||||
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText()))
|
trySendAction(
|
||||||
|
LoginApprovalAction.Internal.DeclineRequestResultReceive(
|
||||||
|
result = authRepository.updateAuthRequest(
|
||||||
|
requestId = mutableStateFlow.value.requestId,
|
||||||
|
masterPasswordHash = mutableStateFlow.value.masterPasswordHash,
|
||||||
|
publicKey = mutableStateFlow.value.publicKey,
|
||||||
|
isApproved = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleCloseClicked() {
|
private fun handleCloseClicked() {
|
||||||
|
@ -69,33 +93,87 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDeclineRequestClicked() {
|
private fun handleDeclineRequestClicked() {
|
||||||
// TODO BIT-1565 implement decline login request
|
viewModelScope.launch {
|
||||||
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText()))
|
trySendAction(
|
||||||
|
LoginApprovalAction.Internal.DeclineRequestResultReceive(
|
||||||
|
result = authRepository.updateAuthRequest(
|
||||||
|
requestId = mutableStateFlow.value.requestId,
|
||||||
|
masterPasswordHash = mutableStateFlow.value.masterPasswordHash,
|
||||||
|
publicKey = mutableStateFlow.value.publicKey,
|
||||||
|
isApproved = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleErrorDialogDismissed() {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(shouldShowErrorDialog = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleApproveRequestResultReceived(
|
||||||
|
action: LoginApprovalAction.Internal.ApproveRequestResultReceive,
|
||||||
|
) {
|
||||||
|
when (action.result) {
|
||||||
|
is AuthRequestResult.Success -> {
|
||||||
|
sendEvent(LoginApprovalEvent.ShowToast(R.string.login_approved.asText()))
|
||||||
|
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AuthRequestResult.Error -> {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(shouldShowErrorDialog = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleAuthRequestResultReceived(
|
private fun handleAuthRequestResultReceived(
|
||||||
action: LoginApprovalAction.Internal.AuthRequestResultReceive,
|
action: LoginApprovalAction.Internal.AuthRequestResultReceive,
|
||||||
) {
|
) {
|
||||||
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
|
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
|
||||||
mutableStateFlow.update {
|
when (val result = action.authRequestResult) {
|
||||||
|
is AuthRequestResult.Success -> mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
viewState = when (val result = action.authRequestResult) {
|
masterPasswordHash = result.authRequest.masterPasswordHash,
|
||||||
is AuthRequestResult.Success -> {
|
publicKey = result.authRequest.publicKey,
|
||||||
LoginApprovalState.ViewState.Content(
|
requestId = result.authRequest.id,
|
||||||
|
viewState = LoginApprovalState.ViewState.Content(
|
||||||
deviceType = result.authRequest.platform,
|
deviceType = result.authRequest.platform,
|
||||||
domainUrl = result.authRequest.originUrl,
|
domainUrl = result.authRequest.originUrl,
|
||||||
email = email,
|
email = email,
|
||||||
fingerprint = result.authRequest.fingerprint,
|
fingerprint = result.authRequest.fingerprint,
|
||||||
ipAddress = result.authRequest.ipAddress,
|
ipAddress = result.authRequest.ipAddress,
|
||||||
time = dateTimeFormatter.format(result.authRequest.creationDate),
|
time = dateTimeFormatter.format(result.authRequest.creationDate),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is AuthRequestResult.Error -> LoginApprovalState.ViewState.Error
|
is AuthRequestResult.Error -> mutableStateFlow.update {
|
||||||
},
|
it.copy(
|
||||||
|
viewState = LoginApprovalState.ViewState.Error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDeclineRequestResultReceived(
|
||||||
|
action: LoginApprovalAction.Internal.DeclineRequestResultReceive,
|
||||||
|
) {
|
||||||
|
when (action.result) {
|
||||||
|
is AuthRequestResult.Success -> {
|
||||||
|
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AuthRequestResult.Error -> {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(shouldShowErrorDialog = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -103,8 +181,13 @@ class LoginApprovalViewModel @Inject constructor(
|
||||||
*/
|
*/
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class LoginApprovalState(
|
data class LoginApprovalState(
|
||||||
val fingerprint: String,
|
|
||||||
val viewState: ViewState,
|
val viewState: ViewState,
|
||||||
|
val shouldShowErrorDialog: Boolean,
|
||||||
|
// Internal
|
||||||
|
val fingerprint: String,
|
||||||
|
val masterPasswordHash: String?,
|
||||||
|
val publicKey: String,
|
||||||
|
val requestId: String,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
/**
|
/**
|
||||||
* Represents the specific view states for the [LoginApprovalScreen].
|
* Represents the specific view states for the [LoginApprovalScreen].
|
||||||
|
@ -176,15 +259,34 @@ sealed class LoginApprovalAction {
|
||||||
*/
|
*/
|
||||||
data object DeclineRequestClick : LoginApprovalAction()
|
data object DeclineRequestClick : LoginApprovalAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User dismissed the error dialog.
|
||||||
|
*/
|
||||||
|
data object ErrorDialogDismiss : LoginApprovalAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Models action the view model could send itself.
|
* Models action the view model could send itself.
|
||||||
*/
|
*/
|
||||||
sealed class Internal : LoginApprovalAction() {
|
sealed class Internal : LoginApprovalAction() {
|
||||||
|
/**
|
||||||
|
* A new result for a request to approve this request has been received.
|
||||||
|
*/
|
||||||
|
data class ApproveRequestResultReceive(
|
||||||
|
val result: AuthRequestResult,
|
||||||
|
) : Internal()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An auth request result has been received to populate the data on the screen.
|
* An auth request result has been received to populate the data on the screen.
|
||||||
*/
|
*/
|
||||||
data class AuthRequestResultReceive(
|
data class AuthRequestResultReceive(
|
||||||
val authRequestResult: AuthRequestResult,
|
val authRequestResult: AuthRequestResult,
|
||||||
) : Internal()
|
) : Internal()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new result for a request to decline this request has been received.
|
||||||
|
*/
|
||||||
|
data class DeclineRequestResultReceive(
|
||||||
|
val result: AuthRequestResult,
|
||||||
|
) : Internal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,8 +35,10 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWithIcon
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWithIcon
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
||||||
|
@ -72,6 +74,15 @@ fun PendingRequestsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LivecycleEventEffect { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
viewModel.trySendAction(PendingRequestsAction.LifecycleResume)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
BitwardenScaffold(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
@ -23,7 +23,7 @@ private const val KEY_STATE = "state"
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PendingRequestsViewModel @Inject constructor(
|
class PendingRequestsViewModel @Inject constructor(
|
||||||
authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<PendingRequestsState, PendingRequestsEvent, PendingRequestsAction>(
|
) : BaseViewModel<PendingRequestsState, PendingRequestsEvent, PendingRequestsAction>(
|
||||||
initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState(
|
initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState(
|
||||||
|
@ -36,19 +36,14 @@ class PendingRequestsViewModel @Inject constructor(
|
||||||
.withZone(TimeZone.getDefault().toZoneId())
|
.withZone(TimeZone.getDefault().toZoneId())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
updateAuthRequestList()
|
||||||
trySendAction(
|
|
||||||
PendingRequestsAction.Internal.AuthRequestsResultReceive(
|
|
||||||
authRequestsResult = authRepository.getAuthRequests(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAction(action: PendingRequestsAction) {
|
override fun handleAction(action: PendingRequestsAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
PendingRequestsAction.CloseClick -> handleCloseClicked()
|
PendingRequestsAction.CloseClick -> handleCloseClicked()
|
||||||
PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked()
|
PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked()
|
||||||
|
PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed()
|
||||||
is PendingRequestsAction.PendingRequestRowClick -> {
|
is PendingRequestsAction.PendingRequestRowClick -> {
|
||||||
handlePendingRequestRowClicked(action)
|
handlePendingRequestRowClicked(action)
|
||||||
}
|
}
|
||||||
|
@ -67,6 +62,10 @@ class PendingRequestsViewModel @Inject constructor(
|
||||||
sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText()))
|
sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleOnLifecycleResumed() {
|
||||||
|
updateAuthRequestList()
|
||||||
|
}
|
||||||
|
|
||||||
private fun handlePendingRequestRowClicked(
|
private fun handlePendingRequestRowClicked(
|
||||||
action: PendingRequestsAction.PendingRequestRowClick,
|
action: PendingRequestsAction.PendingRequestRowClick,
|
||||||
) {
|
) {
|
||||||
|
@ -102,6 +101,17 @@ class PendingRequestsViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateAuthRequestList() {
|
||||||
|
// TODO BIT-1574: Display pull to refresh
|
||||||
|
viewModelScope.launch {
|
||||||
|
trySendAction(
|
||||||
|
PendingRequestsAction.Internal.AuthRequestsResultReceive(
|
||||||
|
authRequestsResult = authRepository.getAuthRequests(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -195,6 +205,11 @@ sealed class PendingRequestsAction {
|
||||||
*/
|
*/
|
||||||
data object DeclineAllRequestsClick : PendingRequestsAction()
|
data object DeclineAllRequestsClick : PendingRequestsAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen has been re-opened and should be updated.
|
||||||
|
*/
|
||||||
|
data object LifecycleResume : PendingRequestsAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user has clicked one of the pending request rows.
|
* The user has clicked one of the pending request rows.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
package com.x8bit.bitwarden.data.auth.datasource.network.service
|
package com.x8bit.bitwarden.data.auth.datasource.network.service
|
||||||
|
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
|
||||||
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
|
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
class AuthRequestsServiceTest : BaseServiceTest() {
|
class AuthRequestsServiceTest : BaseServiceTest() {
|
||||||
|
|
||||||
|
@ -45,4 +48,57 @@ class AuthRequestsServiceTest : BaseServiceTest() {
|
||||||
val actual = service.getAuthRequests()
|
val actual = service.getAuthRequests()
|
||||||
assertTrue(actual.isSuccess)
|
assertTrue(actual.isSuccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAuthRequest when request response is Failure should return Failure`() = runTest {
|
||||||
|
val response = MockResponse().setResponseCode(400)
|
||||||
|
server.enqueue(response)
|
||||||
|
val actual = service.updateAuthRequest(
|
||||||
|
requestId = "userId",
|
||||||
|
deviceId = "deviceId",
|
||||||
|
key = "secureKey",
|
||||||
|
masterPasswordHash = null,
|
||||||
|
isApproved = true,
|
||||||
|
)
|
||||||
|
assertTrue(actual.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAuthRequest when request response is Success should return Success`() = runTest {
|
||||||
|
val json = """
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"publicKey": "2",
|
||||||
|
"requestDeviceType": "Android",
|
||||||
|
"requestIpAddress": "1.0.0.1",
|
||||||
|
"key": "key",
|
||||||
|
"masterPasswordHash": "verySecureHash",
|
||||||
|
"creationDate": "2024-09-13T01:00:00.00Z",
|
||||||
|
"requestApproved": true,
|
||||||
|
"origin": "www.bitwarden.com"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
val expected = AuthRequestsResponseJson.AuthRequest(
|
||||||
|
id = "1",
|
||||||
|
publicKey = "2",
|
||||||
|
platform = "Android",
|
||||||
|
ipAddress = "1.0.0.1",
|
||||||
|
key = "key",
|
||||||
|
masterPasswordHash = "verySecureHash",
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = true,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
)
|
||||||
|
val response = MockResponse().setBody(json).setResponseCode(200)
|
||||||
|
server.enqueue(response)
|
||||||
|
val actual = service.updateAuthRequest(
|
||||||
|
requestId = "userId",
|
||||||
|
deviceId = "deviceId",
|
||||||
|
key = "secureKey",
|
||||||
|
masterPasswordHash = "verySecureHash",
|
||||||
|
isApproved = true,
|
||||||
|
)
|
||||||
|
assertEquals(Result.success(expected), actual)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,6 +70,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito
|
||||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
|
@ -158,6 +159,14 @@ class AuthRepositoryTest {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
private val vaultSdkSource = mockk<VaultSdkSource> {
|
||||||
|
coEvery {
|
||||||
|
getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
} returns "AsymmetricEncString".asSuccess()
|
||||||
|
}
|
||||||
private val userLogoutManager: UserLogoutManager = mockk {
|
private val userLogoutManager: UserLogoutManager = mockk {
|
||||||
every { logout(any()) } just runs
|
every { logout(any()) } just runs
|
||||||
}
|
}
|
||||||
|
@ -173,6 +182,7 @@ class AuthRepositoryTest {
|
||||||
newAuthRequestService = newAuthRequestService,
|
newAuthRequestService = newAuthRequestService,
|
||||||
organizationService = organizationService,
|
organizationService = organizationService,
|
||||||
authSdkSource = authSdkSource,
|
authSdkSource = authSdkSource,
|
||||||
|
vaultSdkSource = vaultSdkSource,
|
||||||
authDiskSource = fakeAuthDiskSource,
|
authDiskSource = fakeAuthDiskSource,
|
||||||
environmentRepository = fakeEnvironmentRepository,
|
environmentRepository = fakeEnvironmentRepository,
|
||||||
settingsRepository = settingsRepository,
|
settingsRepository = settingsRepository,
|
||||||
|
@ -2413,6 +2423,143 @@ class AuthRepositoryTest {
|
||||||
assertEquals(expected, result)
|
assertEquals(expected, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAuthRequest should return failure when sdk returns failure`() = runTest {
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
} returns Throwable("Fail").asFailure()
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
val result = repository.updateAuthRequest(
|
||||||
|
requestId = "requestId",
|
||||||
|
masterPasswordHash = "masterPasswordHash",
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(AuthRequestResult.Error, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateAuthRequest should return failure when service returns failure`() = runTest {
|
||||||
|
val requestId = "requestId"
|
||||||
|
val passwordHash = "masterPasswordHash"
|
||||||
|
val encodedKey = "encodedKey"
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
} returns encodedKey.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.updateAuthRequest(
|
||||||
|
requestId = requestId,
|
||||||
|
masterPasswordHash = passwordHash,
|
||||||
|
key = encodedKey,
|
||||||
|
deviceId = UNIQUE_APP_ID,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
} returns Throwable("Mission failed").asFailure()
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
val result = repository.updateAuthRequest(
|
||||||
|
requestId = "requestId",
|
||||||
|
masterPasswordHash = "masterPasswordHash",
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(AuthRequestResult.Error, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
@Test
|
||||||
|
fun `updateAuthRequest should return success when service & sdk return success`() = runTest {
|
||||||
|
val requestId = "requestId"
|
||||||
|
val passwordHash = "masterPasswordHash"
|
||||||
|
val encodedKey = "encodedKey"
|
||||||
|
val responseJson = AuthRequestsResponseJson.AuthRequest(
|
||||||
|
id = requestId,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
platform = "Android",
|
||||||
|
ipAddress = "192.168.0.1",
|
||||||
|
key = "key",
|
||||||
|
masterPasswordHash = passwordHash,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = true,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
)
|
||||||
|
val expected = AuthRequestResult.Success(
|
||||||
|
authRequest = AuthRequest(
|
||||||
|
id = requestId,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
platform = "Android",
|
||||||
|
ipAddress = "192.168.0.1",
|
||||||
|
key = "key",
|
||||||
|
masterPasswordHash = passwordHash,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = true,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
fingerprint = "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
} returns encodedKey.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
authRequestsService.updateAuthRequest(
|
||||||
|
requestId = requestId,
|
||||||
|
masterPasswordHash = passwordHash,
|
||||||
|
key = encodedKey,
|
||||||
|
deviceId = UNIQUE_APP_ID,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
} returns responseJson.asSuccess()
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||||
|
|
||||||
|
val result = repository.updateAuthRequest(
|
||||||
|
requestId = requestId,
|
||||||
|
masterPasswordHash = passwordHash,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
vaultSdkSource.getAuthRequestKey(
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
userId = USER_ID_1,
|
||||||
|
)
|
||||||
|
authRequestsService.updateAuthRequest(
|
||||||
|
requestId = requestId,
|
||||||
|
masterPasswordHash = passwordHash,
|
||||||
|
key = encodedKey,
|
||||||
|
deviceId = UNIQUE_APP_ID,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getIsKnownDevice should return failure when service returns failure`() = runTest {
|
fun `getIsKnownDevice should return failure when service returns failure`() = runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
|
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assert
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
|
import androidx.compose.ui.test.isDialog
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
@ -43,20 +47,6 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on Confirm login should send ApproveRequestClick`() = runTest {
|
fun `on Confirm login should send ApproveRequestClick`() = runTest {
|
||||||
// Set to content state to show appropriate buttons
|
|
||||||
mutableStateFlow.tryEmit(
|
|
||||||
LoginApprovalState(
|
|
||||||
fingerprint = FINGERPRINT,
|
|
||||||
viewState = LoginApprovalState.ViewState.Content(
|
|
||||||
deviceType = "Android",
|
|
||||||
domainUrl = "bitwarden.com",
|
|
||||||
email = "test@bitwarden.com",
|
|
||||||
fingerprint = FINGERPRINT,
|
|
||||||
ipAddress = "1.0.0.1",
|
|
||||||
time = "now",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Confirm login")
|
.onNodeWithText("Confirm login")
|
||||||
.performScrollTo()
|
.performScrollTo()
|
||||||
|
@ -69,20 +59,6 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on Deny login should send DeclineRequestClick`() = runTest {
|
fun `on Deny login should send DeclineRequestClick`() = runTest {
|
||||||
// Set to content state to show appropriate buttons
|
|
||||||
mutableStateFlow.tryEmit(
|
|
||||||
LoginApprovalState(
|
|
||||||
fingerprint = FINGERPRINT,
|
|
||||||
viewState = LoginApprovalState.ViewState.Content(
|
|
||||||
deviceType = "Android",
|
|
||||||
domainUrl = "bitwarden.com",
|
|
||||||
email = "test@bitwarden.com",
|
|
||||||
fingerprint = FINGERPRINT,
|
|
||||||
ipAddress = "1.0.0.1",
|
|
||||||
time = "now",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Deny login")
|
.onNodeWithText("Deny login")
|
||||||
.performScrollTo()
|
.performScrollTo()
|
||||||
|
@ -91,10 +67,43 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
||||||
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
|
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on error dialog dismiss click should send ErrorDialogDismiss`() = runTest {
|
||||||
|
mutableStateFlow.tryEmit(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
shouldShowErrorDialog = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("An error has occurred.")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Ok")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val FINGERPRINT = "fingerprint"
|
private const val FINGERPRINT = "fingerprint"
|
||||||
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
||||||
fingerprint = FINGERPRINT,
|
fingerprint = FINGERPRINT,
|
||||||
viewState = LoginApprovalState.ViewState.Loading,
|
masterPasswordHash = null,
|
||||||
|
publicKey = "publicKey",
|
||||||
|
requestId = "",
|
||||||
|
shouldShowErrorDialog = false,
|
||||||
|
viewState = LoginApprovalState.ViewState.Content(
|
||||||
|
deviceType = "Android",
|
||||||
|
domainUrl = "bitwarden.com",
|
||||||
|
email = "test@bitwarden.com",
|
||||||
|
fingerprint = FINGERPRINT,
|
||||||
|
ipAddress = "1.0.0.1",
|
||||||
|
time = "now",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -82,23 +81,72 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on ApproveRequestClick should emit ShowToast`() = runTest {
|
fun `on ApproveRequestClick should approve auth request`() = runTest {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
viewModel.eventFlow.test {
|
coEvery {
|
||||||
|
mockAuthRepository.updateAuthRequest(
|
||||||
|
requestId = REQUEST_ID,
|
||||||
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = true,
|
||||||
|
)
|
||||||
|
} returns AuthRequestResult.Success(AUTH_REQUEST)
|
||||||
|
|
||||||
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
|
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
|
||||||
assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem())
|
|
||||||
|
coVerify {
|
||||||
|
mockAuthRepository.updateAuthRequest(
|
||||||
|
requestId = REQUEST_ID,
|
||||||
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on DeclineRequestClick should emit ShowToast`() = runTest {
|
fun `on DeclineRequestClick should deny auth request`() = runTest {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
viewModel.eventFlow.test {
|
coEvery {
|
||||||
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
|
mockAuthRepository.updateAuthRequest(
|
||||||
assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem())
|
requestId = REQUEST_ID,
|
||||||
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
} returns AuthRequestResult.Success(AUTH_REQUEST)
|
||||||
|
|
||||||
|
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockAuthRepository.updateAuthRequest(
|
||||||
|
requestId = REQUEST_ID,
|
||||||
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on ErrorDialogDismiss should update state`() = runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
coEvery {
|
||||||
|
mockAuthRepository.updateAuthRequest(
|
||||||
|
requestId = REQUEST_ID,
|
||||||
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
isApproved = false,
|
||||||
|
)
|
||||||
|
} returns AuthRequestResult.Error
|
||||||
|
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
|
||||||
|
|
||||||
|
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE.copy(shouldShowErrorDialog = true))
|
||||||
|
viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss)
|
||||||
|
|
||||||
|
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE.copy(shouldShowErrorDialog = false))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createViewModel(
|
private fun createViewModel(
|
||||||
authRepository: AuthRepository = mockAuthRepository,
|
authRepository: AuthRepository = mockAuthRepository,
|
||||||
state: LoginApprovalState? = DEFAULT_STATE,
|
state: LoginApprovalState? = DEFAULT_STATE,
|
||||||
|
@ -112,8 +160,15 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
private const val EMAIL = "test@bitwarden.com"
|
private const val EMAIL = "test@bitwarden.com"
|
||||||
private const val FINGERPRINT = "fingerprint"
|
private const val FINGERPRINT = "fingerprint"
|
||||||
|
private const val PASSWORD_HASH = "verySecureHash"
|
||||||
|
private const val PUBLIC_KEY = "publicKey"
|
||||||
|
private const val REQUEST_ID = "requestId"
|
||||||
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
||||||
fingerprint = FINGERPRINT,
|
fingerprint = FINGERPRINT,
|
||||||
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
|
publicKey = PUBLIC_KEY,
|
||||||
|
requestId = REQUEST_ID,
|
||||||
|
shouldShowErrorDialog = false,
|
||||||
viewState = LoginApprovalState.ViewState.Content(
|
viewState = LoginApprovalState.ViewState.Content(
|
||||||
deviceType = "Android",
|
deviceType = "Android",
|
||||||
domainUrl = "www.bitwarden.com",
|
domainUrl = "www.bitwarden.com",
|
||||||
|
@ -142,12 +197,12 @@ private val DEFAULT_USER_STATE = UserState(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
private val AUTH_REQUEST = AuthRequest(
|
private val AUTH_REQUEST = AuthRequest(
|
||||||
id = "1",
|
id = REQUEST_ID,
|
||||||
publicKey = "2",
|
publicKey = PUBLIC_KEY,
|
||||||
platform = "Android",
|
platform = "Android",
|
||||||
ipAddress = "1.0.0.1",
|
ipAddress = "1.0.0.1",
|
||||||
key = "public",
|
key = "public",
|
||||||
masterPasswordHash = "verySecureHash",
|
masterPasswordHash = PASSWORD_HASH,
|
||||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||||
responseDate = null,
|
responseDate = null,
|
||||||
requestApproved = true,
|
requestApproved = true,
|
||||||
|
|
|
@ -177,6 +177,75 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
@Test
|
||||||
|
fun `on LifecycleResume should update state`() = runTest {
|
||||||
|
coEvery {
|
||||||
|
authRepository.getAuthRequests()
|
||||||
|
} returns AuthRequestsResult.Success(emptyList())
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE,
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
authRepository.getAuthRequests()
|
||||||
|
} returns AuthRequestsResult.Success(
|
||||||
|
authRequests = listOf(
|
||||||
|
AuthRequest(
|
||||||
|
id = "1",
|
||||||
|
publicKey = "publicKey-1",
|
||||||
|
platform = "Android",
|
||||||
|
ipAddress = "192.168.0.1",
|
||||||
|
key = "publicKey",
|
||||||
|
masterPasswordHash = "verySecureHash",
|
||||||
|
creationDate = ZonedDateTime.parse("2023-08-24T17:11Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = true,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
fingerprint = "pantry-overdue-survive-sleep-jab",
|
||||||
|
),
|
||||||
|
AuthRequest(
|
||||||
|
id = "2",
|
||||||
|
publicKey = "publicKey-2",
|
||||||
|
platform = "iOS",
|
||||||
|
ipAddress = "192.168.0.2",
|
||||||
|
key = "publicKey",
|
||||||
|
masterPasswordHash = "verySecureHash",
|
||||||
|
creationDate = ZonedDateTime.parse("2023-08-21T15:43Z"),
|
||||||
|
responseDate = null,
|
||||||
|
requestApproved = false,
|
||||||
|
originUrl = "www.bitwarden.com",
|
||||||
|
fingerprint = "erupt-anew-matchbook-disk-student",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val expected = DEFAULT_STATE.copy(
|
||||||
|
viewState = PendingRequestsState.ViewState.Content(
|
||||||
|
requests = listOf(
|
||||||
|
PendingRequestsState.ViewState.Content.PendingLoginRequest(
|
||||||
|
fingerprintPhrase = "pantry-overdue-survive-sleep-jab",
|
||||||
|
platform = "Android",
|
||||||
|
timestamp = "8/24/23 05:11 PM",
|
||||||
|
),
|
||||||
|
PendingRequestsState.ViewState.Content.PendingLoginRequest(
|
||||||
|
fingerprintPhrase = "erupt-anew-matchbook-disk-student",
|
||||||
|
platform = "iOS",
|
||||||
|
timestamp = "8/21/23 03:43 PM",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
viewModel.trySendAction(PendingRequestsAction.LifecycleResume)
|
||||||
|
assertEquals(expected, viewModel.stateFlow.value)
|
||||||
|
|
||||||
|
coVerify(exactly = 2) {
|
||||||
|
authRepository.getAuthRequests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createViewModel(
|
private fun createViewModel(
|
||||||
state: PendingRequestsState? = DEFAULT_STATE,
|
state: PendingRequestsState? = DEFAULT_STATE,
|
||||||
): PendingRequestsViewModel = PendingRequestsViewModel(
|
): PendingRequestsViewModel = PendingRequestsViewModel(
|
||||||
|
|
Loading…
Reference in a new issue