Poll for auth request updates (#939)

This commit is contained in:
David Perez 2024-02-01 02:16:07 -06:00 committed by Álison Fernandes
parent 624e60fd71
commit 33c64db85c
8 changed files with 841 additions and 124 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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