mirror of
https://github.com/bitwarden/android.git
synced 2024-11-23 01:46:00 +03:00
Poll for auth request updates (#939)
This commit is contained in:
parent
624e60fd71
commit
33c64db85c
8 changed files with 841 additions and 124 deletions
|
@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
|
@ -227,9 +228,14 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
fun createAuthRequestWithUpdates(email: String): Flow<CreateAuthRequestResult>
|
||||
|
||||
/**
|
||||
* Get an auth request by its [fingerprint].
|
||||
* Get an auth request by its [fingerprint] and emits updates for that request.
|
||||
*/
|
||||
suspend fun getAuthRequest(fingerprint: String): AuthRequestResult
|
||||
fun getAuthRequestByFingerprintFlow(fingerprint: String): Flow<AuthRequestUpdatesResult>
|
||||
|
||||
/**
|
||||
* Get an auth request by its request ID and emits updates for that request.
|
||||
*/
|
||||
fun getAuthRequestByIdFlow(requestId: String): Flow<AuthRequestUpdatesResult>
|
||||
|
||||
/**
|
||||
* Get a list of the current user's [AuthRequest]s.
|
||||
|
|
|
@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
|
|||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
|
@ -98,9 +99,11 @@ import kotlinx.coroutines.flow.stateIn
|
|||
import kotlinx.coroutines.isActive
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
private const val PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS: Long = 15L * 60L * 1_000L
|
||||
private const val PASSWORDLESS_NOTIFICATION_RETRY_INTERVAL_MILLIS: Long = 4L * 1_000L
|
||||
private const val PASSWORDLESS_APPROVER_INTERVAL_MILLIS: Long = 5L * 60L * 1_000L
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthRepository].
|
||||
|
@ -897,20 +900,113 @@ class AuthRepositoryImpl(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun getAuthRequest(
|
||||
fingerprint: String,
|
||||
): AuthRequestResult =
|
||||
when (val authRequestsResult = getAuthRequests()) {
|
||||
AuthRequestsResult.Error -> AuthRequestResult.Error
|
||||
is AuthRequestsResult.Success -> {
|
||||
val request = authRequestsResult.authRequests
|
||||
.firstOrNull { it.fingerprint == fingerprint }
|
||||
private fun getAuthRequest(
|
||||
initialRequest: suspend () -> AuthRequestUpdatesResult,
|
||||
): Flow<AuthRequestUpdatesResult> = flow {
|
||||
val result = initialRequest()
|
||||
emit(result)
|
||||
if (result is AuthRequestUpdatesResult.Error) return@flow
|
||||
var isComplete = false
|
||||
while (coroutineContext.isActive && !isComplete) {
|
||||
delay(PASSWORDLESS_APPROVER_INTERVAL_MILLIS)
|
||||
val updateResult = result as AuthRequestUpdatesResult.Update
|
||||
authRequestsService
|
||||
.getAuthRequest(result.authRequest.id)
|
||||
.map { request ->
|
||||
AuthRequest(
|
||||
id = request.id,
|
||||
publicKey = request.publicKey,
|
||||
platform = request.platform,
|
||||
ipAddress = request.ipAddress,
|
||||
key = request.key,
|
||||
masterPasswordHash = request.masterPasswordHash,
|
||||
creationDate = request.creationDate,
|
||||
responseDate = request.responseDate,
|
||||
requestApproved = request.requestApproved ?: false,
|
||||
originUrl = request.originUrl,
|
||||
fingerprint = updateResult.authRequest.fingerprint,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onFailure = { emit(AuthRequestUpdatesResult.Error) },
|
||||
onSuccess = { updateAuthRequest ->
|
||||
when {
|
||||
updateAuthRequest.requestApproved -> {
|
||||
isComplete = true
|
||||
emit(AuthRequestUpdatesResult.Approved)
|
||||
}
|
||||
|
||||
request
|
||||
?.let { AuthRequestResult.Success(it) }
|
||||
?: AuthRequestResult.Error
|
||||
!updateAuthRequest.requestApproved &&
|
||||
updateAuthRequest.responseDate != null -> {
|
||||
isComplete = true
|
||||
emit(AuthRequestUpdatesResult.Declined)
|
||||
}
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
isComplete = true
|
||||
emit(AuthRequestUpdatesResult.Expired)
|
||||
}
|
||||
|
||||
else -> {
|
||||
emit(AuthRequestUpdatesResult.Update(updateAuthRequest))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAuthRequestByFingerprintFlow(
|
||||
fingerprint: String,
|
||||
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
|
||||
when (val authRequestsResult = getAuthRequests()) {
|
||||
AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error
|
||||
is AuthRequestsResult.Success -> {
|
||||
authRequestsResult
|
||||
.authRequests
|
||||
.firstOrNull { it.fingerprint == fingerprint }
|
||||
?.let { AuthRequestUpdatesResult.Update(it) }
|
||||
?: AuthRequestUpdatesResult.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAuthRequestByIdFlow(
|
||||
requestId: String,
|
||||
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
|
||||
authRequestsService
|
||||
.getAuthRequest(requestId)
|
||||
.map { request ->
|
||||
when (val result = getFingerprintPhrase(request.publicKey)) {
|
||||
is UserFingerprintResult.Error -> null
|
||||
is UserFingerprintResult.Success -> AuthRequest(
|
||||
id = request.id,
|
||||
publicKey = request.publicKey,
|
||||
platform = request.platform,
|
||||
ipAddress = request.ipAddress,
|
||||
key = request.key,
|
||||
masterPasswordHash = request.masterPasswordHash,
|
||||
creationDate = request.creationDate,
|
||||
responseDate = request.responseDate,
|
||||
requestApproved = request.requestApproved ?: false,
|
||||
originUrl = request.originUrl,
|
||||
fingerprint = result.fingerprint,
|
||||
)
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
onFailure = { AuthRequestUpdatesResult.Error },
|
||||
onSuccess = { authRequest ->
|
||||
authRequest
|
||||
?.let { AuthRequestUpdatesResult.Update(it) }
|
||||
?: AuthRequestUpdatesResult.Error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getAuthRequests(): AuthRequestsResult =
|
||||
authRequestsService
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of an authorization approval request.
|
||||
*/
|
||||
sealed class AuthRequestUpdatesResult {
|
||||
/**
|
||||
* Models the data returned when creating an auth request.
|
||||
*/
|
||||
data class Update(
|
||||
val authRequest: AuthRequest,
|
||||
) : AuthRequestUpdatesResult()
|
||||
|
||||
/**
|
||||
* The auth request has been approved.
|
||||
*/
|
||||
data object Approved : AuthRequestUpdatesResult()
|
||||
|
||||
/**
|
||||
* There was an error getting the user's auth requests.
|
||||
*/
|
||||
data object Error : AuthRequestUpdatesResult()
|
||||
|
||||
/**
|
||||
* The auth request has been declined.
|
||||
*/
|
||||
data object Declined : AuthRequestUpdatesResult()
|
||||
|
||||
/**
|
||||
* The auth request has expired.
|
||||
*/
|
||||
data object Expired : AuthRequestUpdatesResult()
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
@ -42,6 +43,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
|
|||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
||||
|
||||
|
@ -53,6 +56,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
|
|||
@Composable
|
||||
fun LoginApprovalScreen(
|
||||
viewModel: LoginApprovalViewModel = hiltViewModel(),
|
||||
exitManager: ExitManager = LocalExitManager.current,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsState()
|
||||
|
@ -60,6 +64,7 @@ fun LoginApprovalScreen(
|
|||
val resources = context.resources
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LoginApprovalEvent.ExitApp -> exitManager.exitApplication()
|
||||
LoginApprovalEvent.NavigateBack -> onNavigateBack()
|
||||
|
||||
is LoginApprovalEvent.ShowToast -> {
|
||||
|
@ -82,6 +87,11 @@ fun LoginApprovalScreen(
|
|||
},
|
||||
)
|
||||
|
||||
BackHandler(
|
||||
onBack = remember(viewModel) {
|
||||
{ viewModel.trySendAction(LoginApprovalAction.CloseClick) }
|
||||
},
|
||||
)
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
|
|
|
@ -6,10 +6,16 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
@ -25,17 +31,25 @@ private const val KEY_STATE = "state"
|
|||
@HiltViewModel
|
||||
class LoginApprovalViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LoginApprovalState, LoginApprovalEvent, LoginApprovalAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: LoginApprovalState(
|
||||
fingerprint = requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint),
|
||||
masterPasswordHash = null,
|
||||
publicKey = "",
|
||||
requestId = "",
|
||||
shouldShowErrorDialog = false,
|
||||
viewState = LoginApprovalState.ViewState.Loading,
|
||||
),
|
||||
?: run {
|
||||
val specialCircumstance = specialCircumstanceManager.specialCircumstance
|
||||
as? SpecialCircumstance.PasswordlessRequest
|
||||
LoginApprovalState(
|
||||
specialCircumstance = specialCircumstance,
|
||||
fingerprint = specialCircumstance
|
||||
?.let { "" }
|
||||
?: requireNotNull(LoginApprovalArgs(savedStateHandle).fingerprint),
|
||||
masterPasswordHash = null,
|
||||
publicKey = "",
|
||||
requestId = "",
|
||||
shouldShowErrorDialog = false,
|
||||
viewState = LoginApprovalState.ViewState.Loading,
|
||||
)
|
||||
},
|
||||
) {
|
||||
private val dateTimeFormatter
|
||||
get() = DateTimeFormatter
|
||||
|
@ -43,13 +57,22 @@ class LoginApprovalViewModel @Inject constructor(
|
|||
.withZone(TimeZone.getDefault().toZoneId())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
trySendAction(
|
||||
LoginApprovalAction.Internal.AuthRequestResultReceive(
|
||||
authRequestResult = authRepository.getAuthRequest(state.fingerprint),
|
||||
),
|
||||
)
|
||||
}
|
||||
state
|
||||
.specialCircumstance
|
||||
?.let {
|
||||
authRepository
|
||||
.getAuthRequestByIdFlow(it.passwordlessRequestData.loginRequestId)
|
||||
.map { LoginApprovalAction.Internal.AuthRequestResultReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
?: run {
|
||||
authRepository
|
||||
.getAuthRequestByFingerprintFlow(state.fingerprint)
|
||||
.map { LoginApprovalAction.Internal.AuthRequestResultReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAction(action: LoginApprovalAction) {
|
||||
|
@ -89,7 +112,7 @@ class LoginApprovalViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleCloseClicked() {
|
||||
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||
closeScreen()
|
||||
}
|
||||
|
||||
private fun handleDeclineRequestClicked() {
|
||||
|
@ -135,8 +158,9 @@ class LoginApprovalViewModel @Inject constructor(
|
|||
) {
|
||||
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
|
||||
when (val result = action.authRequestResult) {
|
||||
is AuthRequestResult.Success -> mutableStateFlow.update {
|
||||
is AuthRequestUpdatesResult.Update -> mutableStateFlow.update {
|
||||
it.copy(
|
||||
fingerprint = result.authRequest.fingerprint,
|
||||
masterPasswordHash = result.authRequest.masterPasswordHash,
|
||||
publicKey = result.authRequest.publicKey,
|
||||
requestId = result.authRequest.id,
|
||||
|
@ -151,11 +175,18 @@ class LoginApprovalViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
is AuthRequestResult.Error -> mutableStateFlow.update {
|
||||
is AuthRequestUpdatesResult.Error -> mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = LoginApprovalState.ViewState.Error,
|
||||
)
|
||||
}
|
||||
|
||||
AuthRequestUpdatesResult.Approved,
|
||||
AuthRequestUpdatesResult.Declined,
|
||||
AuthRequestUpdatesResult.Expired,
|
||||
-> {
|
||||
closeScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,6 +205,14 @@ class LoginApprovalViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeScreen() {
|
||||
if (state.specialCircumstance?.shouldFinishWhenComplete == true) {
|
||||
sendEvent(LoginApprovalEvent.ExitApp)
|
||||
} else {
|
||||
sendEvent(LoginApprovalEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -184,6 +223,7 @@ data class LoginApprovalState(
|
|||
val viewState: ViewState,
|
||||
val shouldShowErrorDialog: Boolean,
|
||||
// Internal
|
||||
val specialCircumstance: SpecialCircumstance.PasswordlessRequest?,
|
||||
val fingerprint: String,
|
||||
val masterPasswordHash: String?,
|
||||
val publicKey: String,
|
||||
|
@ -227,6 +267,11 @@ data class LoginApprovalState(
|
|||
* Models events for the Login Approval screen.
|
||||
*/
|
||||
sealed class LoginApprovalEvent {
|
||||
/**
|
||||
* Closes the app.
|
||||
*/
|
||||
data object ExitApp : LoginApprovalEvent()
|
||||
|
||||
/**
|
||||
* Navigates back.
|
||||
*/
|
||||
|
@ -279,7 +324,7 @@ sealed class LoginApprovalAction {
|
|||
* An auth request result has been received to populate the data on the screen.
|
||||
*/
|
||||
data class AuthRequestResultReceive(
|
||||
val authRequestResult: AuthRequestResult,
|
||||
val authRequestResult: AuthRequestUpdatesResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
|
|
|
@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL
|
|||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||
|
@ -3011,93 +3012,489 @@ class AuthRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequest should return failure when getAuthRequests returns failure`() = runTest {
|
||||
val fingerprint = "fingerprint"
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns Throwable("Fail").asFailure()
|
||||
fun `getAuthRequestByFingerprintFlow should emit failure and cancel flow when getAuthRequests fails`() =
|
||||
runTest {
|
||||
val fingerprint = "fingerprint"
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = repository.getAuthRequest(fingerprint)
|
||||
repository
|
||||
.getAuthRequestByFingerprintFlow(fingerprint)
|
||||
.test {
|
||||
assertEquals(AuthRequestUpdatesResult.Error, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
}
|
||||
}
|
||||
assertEquals(AuthRequestResult.Error, result)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequest should return success when service returns success`() = runTest {
|
||||
val fingerprint = "fingerprint"
|
||||
val responseJson = AuthRequestsResponseJson(
|
||||
authRequests = listOf(
|
||||
AuthRequestsResponseJson.AuthRequest(
|
||||
id = "1",
|
||||
publicKey = PUBLIC_KEY,
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "public",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||
responseDate = null,
|
||||
requestApproved = true,
|
||||
originUrl = "www.bitwarden.com",
|
||||
),
|
||||
),
|
||||
)
|
||||
val expected = AuthRequestResult.Success(
|
||||
authRequest = AuthRequest(
|
||||
id = "1",
|
||||
publicKey = PUBLIC_KEY,
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "public",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||
responseDate = null,
|
||||
requestApproved = true,
|
||||
originUrl = "www.bitwarden.com",
|
||||
fingerprint = fingerprint,
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
fun `getAuthRequestByFingerprintFlow should emit update then not cancel on failure when initial request succeeds and second fails`() =
|
||||
runTest {
|
||||
val authRequestsResponseJson = AuthRequestsResponseJson(
|
||||
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||
)
|
||||
} returns Result.success(fingerprint)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns responseJson.asSuccess()
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
val authRequest = AUTH_REQUEST
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = authRequest,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Error
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns authRequestsResponseJson.asSuccess()
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns Result.failure(mockk())
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
val result = repository.getAuthRequest(fingerprint)
|
||||
repository
|
||||
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
}
|
||||
}
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequest should return error when no matching fingerprint exists`() = runTest {
|
||||
val fingerprint = "fingerprint"
|
||||
val responseJson = AuthRequestsResponseJson(
|
||||
authRequests = listOf(),
|
||||
)
|
||||
val expected = AuthRequestResult.Error
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns responseJson.asSuccess()
|
||||
fun `getAuthRequestByFingerprintFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() =
|
||||
runTest {
|
||||
val responseJsonOne = AuthRequestsResponseJson(
|
||||
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||
)
|
||||
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
requestApproved = true,
|
||||
)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Approved
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns responseJsonOne.asSuccess()
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns Result.success(authRequestsResponse)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
val result = repository.getAuthRequest(fingerprint)
|
||||
repository
|
||||
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByFingerprintFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() =
|
||||
runTest {
|
||||
val responseJsonOne = AuthRequestsResponseJson(
|
||||
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||
)
|
||||
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
responseDate = mockk(),
|
||||
requestApproved = false,
|
||||
)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Declined
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns responseJsonOne.asSuccess()
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns Result.success(authRequestsResponse)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByFingerprintFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() =
|
||||
runTest {
|
||||
val responseJsonOne = AuthRequestsResponseJson(
|
||||
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||
)
|
||||
val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2022-11-12T00:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC),
|
||||
requestApproved = false,
|
||||
)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Expired
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns responseJsonOne.asSuccess()
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns Result.success(authRequestsResponse)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByFingerprintFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() =
|
||||
runTest {
|
||||
val responseJsonOne = AuthRequestsResponseJson(
|
||||
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
|
||||
)
|
||||
val newHash = "evenMoreSecureHash"
|
||||
val authRequestsResponse = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
masterPasswordHash = newHash,
|
||||
requestApproved = false,
|
||||
)
|
||||
val authRequest = AUTH_REQUEST.copy(
|
||||
masterPasswordHash = newHash,
|
||||
requestApproved = false,
|
||||
)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Update(
|
||||
authRequest = authRequest,
|
||||
)
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequests()
|
||||
} returns responseJsonOne.asSuccess()
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns Result.success(authRequestsResponse)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByFingerprintFlow(FINGER_PRINT)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequests()
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByIdFlow should emit failure and cancel flow when getAuthRequests fails`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
repository
|
||||
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
.test {
|
||||
assertEquals(AuthRequestUpdatesResult.Error, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByIdFlow should emit update then not cancel on failure when initial request succeeds and second fails`() =
|
||||
runTest {
|
||||
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||
val authRequestResponseTwo = Result.failure<AuthRequestsResponseJson.AuthRequest>(
|
||||
exception = mockk(),
|
||||
)
|
||||
val authRequest = AUTH_REQUEST.copy(
|
||||
id = REQUEST_ID,
|
||||
)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = authRequest,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Error
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
}
|
||||
coVerify(exactly = 2) {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByIdFlow should emit update then approved and cancel when initial request succeeds and second succeeds with requestApproved`() =
|
||||
runTest {
|
||||
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
requestApproved = true,
|
||||
)
|
||||
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Approved
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
}
|
||||
coVerify(exactly = 2) {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByIdFlow should emit update then declined and cancel when initial request succeeds and second succeeds with valid response data`() =
|
||||
runTest {
|
||||
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
responseDate = mockk(),
|
||||
requestApproved = false,
|
||||
)
|
||||
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Declined
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
}
|
||||
coVerify(exactly = 2) {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByIdFlow should emit update then expired and cancel when initial request succeeds and second succeeds after 15 mins have passed`() =
|
||||
runTest {
|
||||
val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2022-11-12T00:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
creationDate = ZonedDateTime.ofInstant(fixedClock.instant(), ZoneOffset.UTC),
|
||||
requestApproved = false,
|
||||
)
|
||||
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Expired
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
}
|
||||
coVerify(exactly = 2) {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getAuthRequestByIdFlow should emit update then update and not cancel when initial request succeeds and second succeeds before 15 mins passes`() =
|
||||
runTest {
|
||||
val newHash = "evenMoreSecureHash"
|
||||
val authRequestResponseOne = Result.success(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE)
|
||||
val authRequestResponseJson = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.copy(
|
||||
masterPasswordHash = newHash,
|
||||
requestApproved = false,
|
||||
)
|
||||
val authRequestResponseTwo = Result.success(authRequestResponseJson)
|
||||
val authRequest = AUTH_REQUEST.copy(
|
||||
masterPasswordHash = newHash,
|
||||
requestApproved = false,
|
||||
)
|
||||
val expectedOne = AuthRequestUpdatesResult.Update(
|
||||
authRequest = AUTH_REQUEST,
|
||||
)
|
||||
val expectedTwo = AuthRequestUpdatesResult.Update(
|
||||
authRequest = authRequest,
|
||||
)
|
||||
coEvery {
|
||||
authSdkSource.getUserFingerprint(
|
||||
email = EMAIL,
|
||||
publicKey = PUBLIC_KEY,
|
||||
)
|
||||
} returns Result.success(FINGER_PRINT)
|
||||
coEvery {
|
||||
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
|
||||
} returns authRequestResponseOne andThen authRequestResponseTwo
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
repository
|
||||
.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
.test {
|
||||
assertEquals(expectedOne, awaitItem())
|
||||
assertEquals(expectedTwo, awaitItem())
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
|
||||
}
|
||||
coVerify(exactly = 2) {
|
||||
authRequestsService.getAuthRequest(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequests should return failure when service returns failure`() = runTest {
|
||||
|
@ -3650,7 +4047,6 @@ class AuthRepositoryTest {
|
|||
private const val EMAIL_2 = "test2@bitwarden.com"
|
||||
private const val PASSWORD = "password"
|
||||
private const val PASSWORD_HASH = "passwordHash"
|
||||
private const val PASSWORD_HASH_LOCAL = "passwordHashLocal"
|
||||
private const val ACCESS_TOKEN = "accessToken"
|
||||
private const val ACCESS_TOKEN_2 = "accessToken2"
|
||||
private const val REFRESH_TOKEN = "refreshToken"
|
||||
|
@ -3796,5 +4192,33 @@ class AuthRepositoryTest {
|
|||
status = VaultUnlockData.Status.UNLOCKED,
|
||||
),
|
||||
)
|
||||
private const val FINGER_PRINT = "FINGER_PRINT"
|
||||
private const val REQUEST_ID: String = "REQUEST_ID"
|
||||
private val AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE =
|
||||
AuthRequestsResponseJson.AuthRequest(
|
||||
id = REQUEST_ID,
|
||||
publicKey = PUBLIC_KEY,
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "public",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||
responseDate = null,
|
||||
requestApproved = true,
|
||||
originUrl = "www.bitwarden.com",
|
||||
)
|
||||
private val AUTH_REQUEST = AuthRequest(
|
||||
id = REQUEST_ID,
|
||||
publicKey = PUBLIC_KEY,
|
||||
platform = "Android",
|
||||
ipAddress = "192.168.0.1",
|
||||
key = "public",
|
||||
masterPasswordHash = "verySecureHash",
|
||||
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
|
||||
responseDate = null,
|
||||
requestApproved = true,
|
||||
originUrl = "www.bitwarden.com",
|
||||
fingerprint = FINGER_PRINT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,11 @@ import androidx.compose.ui.test.performClick
|
|||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
@ -22,6 +25,9 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
|||
|
||||
private var onNavigateBackCalled = false
|
||||
|
||||
private val exitManager: ExitManager = mockk {
|
||||
every { exitApplication() } just runs
|
||||
}
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<LoginApprovalEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<LoginApprovalViewModel>(relaxed = true) {
|
||||
|
@ -35,6 +41,7 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
|||
LoginApprovalScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
viewModel = viewModel,
|
||||
exitManager = exitManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -45,6 +52,14 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
|||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ExitApp should call exit appliction`() {
|
||||
mutableEventFlow.tryEmit(LoginApprovalEvent.ExitApp)
|
||||
verify(exactly = 1) {
|
||||
exitManager.exitApplication()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on Confirm login should send ApproveRequestClick`() = runTest {
|
||||
composeTestRule
|
||||
|
@ -93,6 +108,7 @@ class LoginApprovalScreenTest : BaseComposeTest() {
|
|||
|
||||
private const val FINGERPRINT = "fingerprint"
|
||||
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
||||
specialCircumstance = null,
|
||||
fingerprint = FINGERPRINT,
|
||||
masterPasswordHash = null,
|
||||
publicKey = "publicKey",
|
||||
|
|
|
@ -5,14 +5,18 @@ import app.cash.turbine.test
|
|||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestUpdatesResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
|
@ -24,11 +28,16 @@ import java.util.TimeZone
|
|||
|
||||
class LoginApprovalViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockSpecialCircumstanceManager: SpecialCircumstanceManager = mockk {
|
||||
every { specialCircumstance } returns null
|
||||
}
|
||||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
|
||||
private val mutableAuthRequestSharedFlow = bufferedMutableSharedFlow<AuthRequestUpdatesResult>()
|
||||
private val mockAuthRepository = mockk<AuthRepository> {
|
||||
coEvery {
|
||||
getAuthRequest(FINGERPRINT)
|
||||
} returns AuthRequestResult.Success(AUTH_REQUEST)
|
||||
getAuthRequestByFingerprintFlow(FINGERPRINT)
|
||||
} returns mutableAuthRequestSharedFlow
|
||||
coEvery { getAuthRequestByIdFlow(REQUEST_ID) } returns mutableAuthRequestSharedFlow
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
|
||||
|
@ -45,38 +54,113 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct and trigger a getAuthRequest call`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
fun `init should call getAuthRequestById when special circumstance is absent`() {
|
||||
createViewModel(state = null)
|
||||
coVerify {
|
||||
mockAuthRepository.getAuthRequest(FINGERPRINT)
|
||||
mockAuthRepository.getAuthRequestByFingerprintFlow(FINGERPRINT)
|
||||
}
|
||||
verify {
|
||||
mockAuthRepository.userStateFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init should call getAuthRequest when special circumstance is present`() {
|
||||
every {
|
||||
mockSpecialCircumstanceManager.specialCircumstance
|
||||
} returns SpecialCircumstance.PasswordlessRequest(
|
||||
passwordlessRequestData = PasswordlessRequestData(
|
||||
loginRequestId = REQUEST_ID,
|
||||
userId = USER_ID,
|
||||
),
|
||||
shouldFinishWhenComplete = false,
|
||||
)
|
||||
createViewModel(state = null)
|
||||
coVerify {
|
||||
mockAuthRepository.getAuthRequestByIdFlow(REQUEST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequest update should update state`() {
|
||||
val expected = DEFAULT_STATE.copy(
|
||||
fingerprint = FINGERPRINT,
|
||||
masterPasswordHash = AUTH_REQUEST.masterPasswordHash,
|
||||
publicKey = AUTH_REQUEST.publicKey,
|
||||
requestId = AUTH_REQUEST.id,
|
||||
viewState = LoginApprovalState.ViewState.Content(
|
||||
deviceType = AUTH_REQUEST.platform,
|
||||
domainUrl = AUTH_REQUEST.originUrl,
|
||||
email = EMAIL,
|
||||
fingerprint = AUTH_REQUEST.fingerprint,
|
||||
ipAddress = AUTH_REQUEST.ipAddress,
|
||||
time = "9/13/24 12:00 AM",
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Update(AUTH_REQUEST))
|
||||
assertEquals(expected, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequest approved should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Approved)
|
||||
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequest declined should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Declined)
|
||||
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequest expired should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Expired)
|
||||
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAuthRequest failure should update state`() {
|
||||
val authRepository = mockk<AuthRepository> {
|
||||
coEvery {
|
||||
getAuthRequest(FINGERPRINT)
|
||||
} returns AuthRequestResult.Error
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
val expected = DEFAULT_STATE.copy(
|
||||
viewState = LoginApprovalState.ViewState.Error,
|
||||
)
|
||||
val viewModel = createViewModel(authRepository = authRepository)
|
||||
val viewModel = createViewModel()
|
||||
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Error)
|
||||
assertEquals(expected, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
fun `on CloseClick should emit NavigateBack when shouldFinishWhenComplete is false`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LoginApprovalAction.CloseClick)
|
||||
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CloseClick should emit ExitApp when shouldFinishWhenComplete is true`() = runTest {
|
||||
every {
|
||||
mockSpecialCircumstanceManager.specialCircumstance
|
||||
} returns SpecialCircumstance.PasswordlessRequest(
|
||||
passwordlessRequestData = PasswordlessRequestData(
|
||||
loginRequestId = REQUEST_ID,
|
||||
userId = USER_ID,
|
||||
),
|
||||
shouldFinishWhenComplete = true,
|
||||
)
|
||||
val viewModel = createViewModel(state = null)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LoginApprovalAction.CloseClick)
|
||||
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
|
||||
assertEquals(LoginApprovalEvent.ExitApp, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,9 +233,11 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private fun createViewModel(
|
||||
authRepository: AuthRepository = mockAuthRepository,
|
||||
specialCircumstanceManager: SpecialCircumstanceManager = mockSpecialCircumstanceManager,
|
||||
state: LoginApprovalState? = DEFAULT_STATE,
|
||||
): LoginApprovalViewModel = LoginApprovalViewModel(
|
||||
authRepository = authRepository,
|
||||
specialCircumstanceManager = specialCircumstanceManager,
|
||||
savedStateHandle = SavedStateHandle()
|
||||
.also { it["fingerprint"] = FINGERPRINT }
|
||||
.apply { set("state", state) },
|
||||
|
@ -165,6 +251,7 @@ private const val PUBLIC_KEY = "publicKey"
|
|||
private const val REQUEST_ID = "requestId"
|
||||
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
|
||||
fingerprint = FINGERPRINT,
|
||||
specialCircumstance = null,
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
publicKey = PUBLIC_KEY,
|
||||
requestId = REQUEST_ID,
|
||||
|
|
Loading…
Reference in a new issue