Listen to updates to login auth requests (#887)

This commit is contained in:
David Perez 2024-01-30 23:25:44 -06:00 committed by Álison Fernandes
parent d6c2969332
commit 526ab51a90
2 changed files with 173 additions and 67 deletions

View file

@ -5,13 +5,16 @@ import androidx.lifecycle.SavedStateHandle
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.CreateAuthRequestResult
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.Job
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
import javax.inject.Inject
@ -32,8 +35,10 @@ class LoginWithDeviceViewModel @Inject constructor(
dialogState = null,
),
) {
private var authJob: Job = Job().apply { complete() }
init {
sendNewAuthRequest()
sendNewAuthRequest(isResend = false)
}
override fun handleAction(action: LoginWithDeviceAction) {
@ -58,22 +63,36 @@ class LoginWithDeviceViewModel @Inject constructor(
}
private fun handleResendNotificationClicked() {
sendNewAuthRequest()
sendNewAuthRequest(isResend = true)
}
private fun handleViewAllLogInOptionsClicked() {
sendEvent(LoginWithDeviceEvent.NavigateBack)
}
@Suppress("LongMethod")
private fun handleNewAuthRequestResultReceived(
action: LoginWithDeviceAction.Internal.NewAuthRequestResultReceive,
) {
when (action.result) {
is AuthRequestResult.Success -> {
when (val result = action.result) {
is CreateAuthRequestResult.Success -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = action.result.authRequest.fingerprint,
fingerprintPhrase = "",
isResendNotificationLoading = false,
),
dialogState = null,
)
}
// TODO: Unlock the vault (BIT-813)
}
is CreateAuthRequestResult.Update -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = result.authRequest.fingerprint,
isResendNotificationLoading = false,
),
dialogState = null,
@ -81,7 +100,7 @@ class LoginWithDeviceViewModel @Inject constructor(
}
}
is AuthRequestResult.Error -> {
is CreateAuthRequestResult.Error -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
@ -95,24 +114,51 @@ class LoginWithDeviceViewModel @Inject constructor(
)
}
}
CreateAuthRequestResult.Declined -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "",
isResendNotificationLoading = false,
),
dialogState = LoginWithDeviceState.DialogState.Error(
title = null,
message = R.string.this_request_is_no_longer_valid.asText(),
),
)
}
}
CreateAuthRequestResult.Expired -> {
mutableStateFlow.update {
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "",
isResendNotificationLoading = false,
),
dialogState = LoginWithDeviceState.DialogState.Error(
title = null,
message = R.string.login_request_has_already_expired.asText(),
),
)
}
}
}
}
private fun sendNewAuthRequest() {
setIsResendNotificationLoading(true)
viewModelScope.launch {
trySendAction(
LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(
result = authRepository.createAuthRequest(
email = state.emailAddress,
),
),
)
}
private fun sendNewAuthRequest(isResend: Boolean) {
setIsResendNotificationLoading(isResend)
authJob.cancel()
authJob = authRepository
.createAuthRequestWithUpdates(email = state.emailAddress)
.map { LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
private fun setIsResendNotificationLoading(isLoading: Boolean) {
updateContent { it.copy(isResendNotificationLoading = isLoading) }
private fun setIsResendNotificationLoading(isResend: Boolean) {
updateContent { it.copy(isResendNotificationLoading = isResend) }
}
private inline fun updateContent(
@ -225,7 +271,7 @@ sealed class LoginWithDeviceAction {
* A new auth request result was received.
*/
data class NewAuthRequestResultReceive(
val result: AuthRequestResult,
val result: CreateAuthRequestResult,
) : Internal()
}
}

View file

@ -2,16 +2,18 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.AuthRequestResponse
import com.x8bit.bitwarden.R
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.CreateAuthRequestResult
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -19,35 +21,37 @@ import java.time.ZonedDateTime
class LoginWithDeviceViewModelTest : BaseViewModelTest() {
private val mutableCreateAuthRequestWithUpdatesFlow =
bufferedMutableSharedFlow<CreateAuthRequestResult>()
private val authRepository = mockk<AuthRepository> {
coEvery {
createAuthRequest(EMAIL)
} returns AuthRequestResult.Success(AUTH_REQUEST)
createAuthRequestWithUpdates(EMAIL)
} returns mutableCreateAuthRequestWithUpdatesFlow
}
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
fun `initial state should be correct`() {
val viewModel = createViewModel(state = null)
assertEquals(
DEFAULT_STATE.copy(viewState = LoginWithDeviceState.ViewState.Loading),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
authRepository.createAuthRequestWithUpdates(EMAIL)
}
coVerify { authRepository.createAuthRequest(EMAIL) }
}
@Test
fun `initial state should be correct when set`() = runTest {
fun `initial state should be correct when set`() {
val newEmail = "newEmail@gmail.com"
coEvery {
authRepository.createAuthRequest(newEmail)
} returns AuthRequestResult.Success(AUTH_REQUEST)
val state = DEFAULT_STATE.copy(emailAddress = newEmail)
val viewModel = createViewModel(state)
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
}
coVerify {
authRepository.createAuthRequest(newEmail)
coEvery {
authRepository.createAuthRequestWithUpdates(newEmail)
} returns mutableCreateAuthRequestWithUpdatesFlow
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
coVerify(exactly = 1) {
authRepository.createAuthRequestWithUpdates(newEmail)
}
}
@ -64,7 +68,7 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `DismissDialog should clear the dialog state`() = runTest {
fun `DismissDialog should clear the dialog state`() {
val initialState = DEFAULT_STATE.copy(
dialogState = LoginWithDeviceState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
@ -77,21 +81,20 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `ResendNotificationClick should create new auth request and update state`() = runTest {
val newFingerprint = "newFingerprint"
coEvery {
authRepository.createAuthRequest(EMAIL)
} returns AuthRequestResult.Success(AUTH_REQUEST.copy(fingerprint = newFingerprint))
fun `ResendNotificationClick should create new auth request and update state`() {
val viewModel = createViewModel()
viewModel.actionChannel.trySend(LoginWithDeviceAction.ResendNotificationClick)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = newFingerprint,
isResendNotificationLoading = true,
),
),
viewModel.stateFlow.value,
)
verify(exactly = 2) {
authRepository.createAuthRequestWithUpdates(EMAIL)
}
}
@Test
@ -108,23 +111,16 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on auth request result success received should show content`() = runTest {
val newFingerprint = "newFingerprint"
fun `on createAuthRequestWithUpdates Update received should show content`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.actionChannel.trySend(
LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(
result = AuthRequestResult.Success(
authRequest = mockk<AuthRequest> {
every { fingerprint } returns newFingerprint
},
),
),
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Update(AUTH_REQUEST),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = newFingerprint,
fingerprintPhrase = FINGERPRINT,
),
),
viewModel.stateFlow.value,
@ -132,17 +128,30 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on fingerprint result failure received should show error dialog`() = runTest {
fun `on createAuthRequestWithUpdates Success received should show content`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.actionChannel.trySend(
LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(
result = AuthRequestResult.Error,
),
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(
CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE),
)
assertEquals(
DEFAULT_STATE.copy(
viewState = LoginWithDeviceState.ViewState.Content(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on createAuthRequestWithUpdates Error received should show content with error dialog`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(CreateAuthRequestResult.Error)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
isResendNotificationLoading = false,
),
@ -155,12 +164,56 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `on createAuthRequestWithUpdates Declined received should show content with error dialog`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(CreateAuthRequestResult.Declined)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
isResendNotificationLoading = false,
),
dialogState = LoginWithDeviceState.DialogState.Error(
title = null,
message = R.string.this_request_is_no_longer_valid.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on createAuthRequestWithUpdates Expired received should show content with error dialog`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
mutableCreateAuthRequestWithUpdatesFlow.tryEmit(CreateAuthRequestResult.Expired)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
fingerprintPhrase = "",
isResendNotificationLoading = false,
),
dialogState = LoginWithDeviceState.DialogState.Error(
title = null,
message = R.string.login_request_has_already_expired.asText(),
),
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
state: LoginWithDeviceState? = DEFAULT_STATE,
): LoginWithDeviceViewModel =
LoginWithDeviceViewModel(
authRepository = authRepository,
savedStateHandle = SavedStateHandle().apply { set("state", state) },
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("email_address", state?.emailAddress ?: EMAIL)
},
)
}
@ -191,3 +244,10 @@ private val AUTH_REQUEST = AuthRequest(
originUrl = "www.bitwarden.com",
fingerprint = FINGERPRINT,
)
private val AUTH_REQUEST_RESPONSE = AuthRequestResponse(
privateKey = "private_key",
publicKey = "public_key",
accessCode = "accessCode",
fingerprint = "fingerprint",
)