From f2053bbb0734c1b301a5e8dd5b08fdba255751f8 Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Mon, 29 Jan 2024 18:48:45 -0700 Subject: [PATCH] BIT-1508: Implement decline all pending requests & add filters (#845) --- .../data/auth/repository/model/AuthRequest.kt | 5 +- .../base/util/ZonedDateTimeExtensions.kt | 17 ++ .../pendingrequests/PendingRequestsScreen.kt | 30 +- .../PendingRequestsViewModel.kt | 105 +++++-- .../base/util/ZonedDateTimeExtensionsTest.kt | 46 +++ .../PendingRequestsScreenTest.kt | 74 +++-- .../PendingRequestsViewModelTest.kt | 261 +++++++++++++----- 7 files changed, 415 insertions(+), 123 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ZonedDateTimeExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ZonedDateTimeExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt index ad0a5bb80..d6c965d5e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.time.ZonedDateTime /** @@ -17,6 +19,7 @@ import java.time.ZonedDateTime * @param originUrl The origin URL of this auth request. * @param fingerprint The fingerprint of this auth request. */ +@Parcelize data class AuthRequest( val id: String, val publicKey: String, @@ -29,4 +32,4 @@ data class AuthRequest( val requestApproved: Boolean, val originUrl: String, val fingerprint: String, -) +) : Parcelable diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ZonedDateTimeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ZonedDateTimeExtensions.kt new file mode 100644 index 000000000..76b86959a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ZonedDateTimeExtensions.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import java.time.Duration +import java.time.ZonedDateTime +import java.util.TimeZone + +/** + * Returns a [Boolean] indicating whether this [ZonedDateTime] is five or more minutes old. + */ +@Suppress("MagicNumber") +fun ZonedDateTime.isOverFiveMinutesOld(): Boolean = + Duration + .between( + this, + ZonedDateTime.now(TimeZone.getDefault().toZoneId()), + ) + .toMinutes() > 5 diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt index 5ec752578..494b410ea 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt @@ -25,7 +25,9 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -44,6 +46,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWith import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography @@ -79,6 +82,7 @@ fun PendingRequestsScreen( Lifecycle.Event.ON_RESUME -> { viewModel.trySendAction(PendingRequestsAction.LifecycleResume) } + else -> Unit } } @@ -107,10 +111,10 @@ fun PendingRequestsScreen( .padding(innerPadding) .fillMaxSize(), state = viewState, - onDeclineAllRequestsClick = remember(viewModel) { + onDeclineAllRequestsConfirm = remember(viewModel) { { viewModel.trySendAction( - PendingRequestsAction.DeclineAllRequestsClick, + PendingRequestsAction.DeclineAllRequestsConfirm, ) } }, @@ -152,13 +156,31 @@ fun PendingRequestsScreen( @Composable private fun PendingRequestsContent( state: PendingRequestsState.ViewState.Content, - onDeclineAllRequestsClick: () -> Unit, + onDeclineAllRequestsConfirm: () -> Unit, onNavigateToLoginApproval: (fingerprint: String) -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier, ) { + var shouldShowDeclineAllRequestsConfirm by remember { mutableStateOf(false) } + + if (shouldShowDeclineAllRequestsConfirm) { + BitwardenTwoButtonDialog( + title = stringResource(R.string.decline_all_requests), + message = stringResource( + id = R.string.are_you_sure_you_want_to_decline_all_pending_log_in_requests, + ), + confirmButtonText = stringResource(R.string.yes), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + onDeclineAllRequestsConfirm() + shouldShowDeclineAllRequestsConfirm = false + }, + onDismissClick = { shouldShowDeclineAllRequestsConfirm = false }, + onDismissRequest = { shouldShowDeclineAllRequestsConfirm = false }, + ) + } LazyColumn( Modifier.padding(bottom = 16.dp), @@ -181,7 +203,7 @@ private fun PendingRequestsContent( BitwardenFilledTonalButtonWithIcon( label = stringResource(id = R.string.decline_all_requests), icon = painterResource(id = R.drawable.ic_trash), - onClick = onDeclineAllRequestsClick, + onClick = { shouldShowDeclineAllRequestsConfirm = true }, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt index bd6c0241a..f6264c7ae 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt @@ -4,10 +4,11 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope 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.AuthRequestsResult 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 com.x8bit.bitwarden.ui.platform.base.util.isOverFiveMinutesOld import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -27,6 +28,7 @@ class PendingRequestsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState( + authRequests = emptyList(), viewState = PendingRequestsState.ViewState.Loading, ), ) { @@ -42,7 +44,7 @@ class PendingRequestsViewModel @Inject constructor( override fun handleAction(action: PendingRequestsAction) { when (action) { PendingRequestsAction.CloseClick -> handleCloseClicked() - PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked() + PendingRequestsAction.DeclineAllRequestsConfirm -> handleDeclineAllRequestsConfirmed() PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed() is PendingRequestsAction.PendingRequestRowClick -> { handlePendingRequestRowClicked(action) @@ -58,8 +60,23 @@ class PendingRequestsViewModel @Inject constructor( sendEvent(PendingRequestsEvent.NavigateBack) } - private fun handleDeclineAllRequestsClicked() { - sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText())) + private fun handleDeclineAllRequestsConfirmed() { + viewModelScope.launch { + mutableStateFlow.update { + it.copy( + viewState = PendingRequestsState.ViewState.Loading, + ) + } + mutableStateFlow.value.authRequests.forEach { request -> + authRepository.updateAuthRequest( + requestId = request.id, + masterPasswordHash = request.masterPasswordHash, + publicKey = request.publicKey, + isApproved = false, + ) + } + updateAuthRequestList() + } } private fun handleOnLifecycleResumed() { @@ -75,30 +92,48 @@ class PendingRequestsViewModel @Inject constructor( private fun handleAuthRequestsResultReceived( action: PendingRequestsAction.Internal.AuthRequestsResultReceive, ) { - mutableStateFlow.update { - it.copy( - viewState = when (val result = action.authRequestsResult) { - is AuthRequestsResult.Success -> { - if (result.authRequests.isEmpty()) { - PendingRequestsState.ViewState.Empty - } else { - PendingRequestsState.ViewState.Content( - requests = result.authRequests.map { authRequest -> - PendingRequestsState.ViewState.Content.PendingLoginRequest( - fingerprintPhrase = authRequest.fingerprint, - platform = authRequest.platform, - timestamp = dateTimeFormatter.format( - authRequest.creationDate, - ), - ) - }, - ) - } + when (val result = action.authRequestsResult) { + is AuthRequestsResult.Success -> { + val requests = result + .authRequests + .filterRespondedAndExpired() + .sortedByDescending { request -> request.creationDate } + .map { request -> + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = request.fingerprint, + platform = request.platform, + timestamp = dateTimeFormatter.format( + request.creationDate, + ), + ) } + if (requests.isEmpty()) { + mutableStateFlow.update { + it.copy( + authRequests = emptyList(), + viewState = PendingRequestsState.ViewState.Empty, + ) + } + } else { + mutableStateFlow.update { + it.copy( + authRequests = result.authRequests, + viewState = PendingRequestsState.ViewState.Content( + requests = requests, + ), + ) + } + } + } - AuthRequestsResult.Error -> PendingRequestsState.ViewState.Error - }, - ) + AuthRequestsResult.Error -> { + mutableStateFlow.update { + it.copy( + authRequests = emptyList(), + viewState = PendingRequestsState.ViewState.Error, + ) + } + } } } @@ -119,6 +154,7 @@ class PendingRequestsViewModel @Inject constructor( */ @Parcelize data class PendingRequestsState( + val authRequests: List, val viewState: ViewState, ) : Parcelable { /** @@ -201,9 +237,9 @@ sealed class PendingRequestsAction { data object CloseClick : PendingRequestsAction() /** - * The user has clicked to deny all login requests. + * The user has confirmed they want to deny all login requests. */ - data object DeclineAllRequestsClick : PendingRequestsAction() + data object DeclineAllRequestsConfirm : PendingRequestsAction() /** * The screen has been re-opened and should be updated. @@ -229,3 +265,16 @@ sealed class PendingRequestsAction { ) : Internal() } } + +/** + * Filters out [AuthRequest]s that match one of the following criteria: + * * The request has been approved. + * * The request has been declined (indicated by it not being approved & having a responseDate). + * * The request has expired (it is at least 5 minutes old). + */ +private fun List.filterRespondedAndExpired() = + filterNot { request -> + request.requestApproved || + request.responseDate != null || + request.creationDate.isOverFiveMinutesOld() + } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ZonedDateTimeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ZonedDateTimeExtensionsTest.kt new file mode 100644 index 000000000..9b5015aa3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ZonedDateTimeExtensionsTest.kt @@ -0,0 +1,46 @@ +package com.x8bit.bitwarden.data.platform.base.util + +import com.x8bit.bitwarden.ui.platform.base.util.isOverFiveMinutesOld +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime +import java.util.TimeZone + +class ZonedDateTimeExtensionsTest { + @BeforeEach + fun setup() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @AfterEach + fun teardown() { + TimeZone.setDefault(null) + } + + @Test + fun `isOverFiveMinutesOld returns true when time is old`() { + val time = ZonedDateTime.parse("2022-09-13T00:00Z") + assertTrue(time.isOverFiveMinutesOld()) + } + + @Test + fun `isOverFiveMinutesOld returns false when time is now`() { + val time = ZonedDateTime.now() + assertFalse(time.isOverFiveMinutesOld()) + } + + @Test + fun `isOverFiveMinutesOld returns false when time is now minus 5 minutes`() { + val time = ZonedDateTime.now().minusMinutes(5) + assertFalse(time.isOverFiveMinutesOld()) + } + + @Test + fun `isOverFiveMinutesOld returns true when time is now minus 6 minutes`() { + val time = ZonedDateTime.now().minusMinutes(6) + assertTrue(time.isOverFiveMinutesOld()) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt index c33f15137..175865991 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt @@ -1,9 +1,13 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -49,34 +53,72 @@ class PendingRequestsScreenTest : BaseComposeTest() { } @Test - fun `on DeclineAllRequestsClick should send DeclineAllRequestsClick`() = runTest { + fun `on decline all requests confirmation should send DeclineAllRequestsConfirm`() = runTest { // set content so the Decline all requests button appears - mutableStateFlow.tryEmit( - PendingRequestsState( - viewState = PendingRequestsState.ViewState.Content( - requests = listOf( - PendingRequestsState.ViewState.Content.PendingLoginRequest( - fingerprintPhrase = "pantry-overdue-survive-sleep-jab", - platform = "iOS", - timestamp = "8/24/2023 11:11 AM", - ), - PendingRequestsState.ViewState.Content.PendingLoginRequest( - fingerprintPhrase = "erupt-anew-matchbook-disk-student", - platform = "Android", - timestamp = "8/21/2023 9:43 AM", - ), + mutableStateFlow.value = PendingRequestsState( + authRequests = listOf(), + viewState = PendingRequestsState.ViewState.Content( + requests = listOf( + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = "pantry-overdue-survive-sleep-jab", + platform = "iOS", + timestamp = "8/24/2023 11:11 AM", + ), + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = "erupt-anew-matchbook-disk-student", + platform = "Android", + timestamp = "8/21/2023 9:43 AM", ), ), ), ) composeTestRule.onNodeWithText("Decline all requests").performClick() + composeTestRule + .onNodeWithText("Yes") + .assert(hasAnyAncestor(isDialog())) + .performClick() + composeTestRule.assertNoDialogExists() + verify { - viewModel.trySendAction(PendingRequestsAction.DeclineAllRequestsClick) + viewModel.trySendAction(PendingRequestsAction.DeclineAllRequestsConfirm) + } + } + + @Test + fun `on decline all requests cancel should hide confirmation dialog`() = runTest { + // set content so the Decline all requests button appears + mutableStateFlow.value = PendingRequestsState( + authRequests = listOf(), + viewState = PendingRequestsState.ViewState.Content( + requests = listOf( + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = "pantry-overdue-survive-sleep-jab", + platform = "iOS", + timestamp = "8/24/2023 11:11 AM", + ), + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = "erupt-anew-matchbook-disk-student", + platform = "Android", + timestamp = "8/21/2023 9:43 AM", + ), + ), + ), + ) + composeTestRule.onNodeWithText("Decline all requests").performClick() + composeTestRule + .onNodeWithText("Cancel") + .assert(hasAnyAncestor(isDialog())) + .performClick() + composeTestRule.assertNoDialogExists() + + verify(exactly = 0) { + viewModel.trySendAction(PendingRequestsAction.DeclineAllRequestsConfirm) } } companion object { val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( + authRequests = emptyList(), viewState = PendingRequestsState.ViewState.Loading, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt index 8aeea4d18..bbb86716a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt @@ -4,9 +4,9 @@ import androidx.lifecycle.SavedStateHandle 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.AuthRequestsResult 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.mockk @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.TimeZone class PendingRequestsViewModelTest : BaseViewModelTest() { @@ -48,52 +49,80 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { } } + @Suppress("LongMethod") @Test - fun `getPendingResults success with content should update state`() { + fun `getPendingResults success with content should update state with some requests filtered`() { + val dateTimeFormatter = DateTimeFormatter + .ofPattern("M/d/yy hh:mm a") + .withZone(TimeZone.getDefault().toZoneId()) + val nowZonedDateTime = ZonedDateTime.now() + val requestList = listOf( + AuthRequest( + id = "1", + publicKey = "publicKey-1", + platform = "Android", + ipAddress = "192.168.0.1", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = nowZonedDateTime, + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "pantry-overdue-survive-sleep-jab", + ), + AuthRequest( + id = "2", + publicKey = "publicKey-2", + platform = "Android", + ipAddress = "192.168.0.1", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = nowZonedDateTime, + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = "fingerprint", + ), + AuthRequest( + id = "3", + publicKey = "publicKey-3", + platform = "iOS", + ipAddress = "192.168.0.2", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.now().minusMinutes(10), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "erupt-anew-matchbook-disk-student", + ), + AuthRequest( + id = "4", + publicKey = "publicKey-4", + platform = "Android", + ipAddress = "192.168.0.1", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = nowZonedDateTime, + responseDate = nowZonedDateTime, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "fingerprint", + ), + ) coEvery { authRepository.getAuthRequests() } returns AuthRequestsResult.Success( - authRequests = listOf( - AuthRequest( - id = "1", - publicKey = "publicKey-1", - platform = "Android", - ipAddress = "192.168.0.1", - key = "publicKey", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2023-08-24T17:11Z"), - responseDate = null, - requestApproved = true, - originUrl = "www.bitwarden.com", - fingerprint = "pantry-overdue-survive-sleep-jab", - ), - AuthRequest( - id = "2", - publicKey = "publicKey-2", - platform = "iOS", - ipAddress = "192.168.0.2", - key = "publicKey", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2023-08-21T15:43Z"), - responseDate = null, - requestApproved = false, - originUrl = "www.bitwarden.com", - fingerprint = "erupt-anew-matchbook-disk-student", - ), - ), + authRequests = requestList, ) val expected = DEFAULT_STATE.copy( + authRequests = requestList, viewState = PendingRequestsState.ViewState.Content( requests = listOf( PendingRequestsState.ViewState.Content.PendingLoginRequest( fingerprintPhrase = "pantry-overdue-survive-sleep-jab", platform = "Android", - timestamp = "8/24/23 05:11 PM", - ), - PendingRequestsState.ViewState.Content.PendingLoginRequest( - fingerprintPhrase = "erupt-anew-matchbook-disk-student", - platform = "iOS", - timestamp = "8/21/23 03:43 PM", + timestamp = nowZonedDateTime.format(dateTimeFormatter), ), ), ), @@ -159,20 +188,82 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { } } + @Suppress("LongMethod") @Test - fun `on DeclineAllRequestsClick should send ShowToast event`() = runTest { + fun `on DeclineAllRequestsConfirm should update all auth requests to declined`() = runTest { + val authRequest1 = AuthRequest( + id = "2", + publicKey = "publicKey-2", + platform = "iOS", + ipAddress = "192.168.0.2", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.now().minusMinutes(5), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "erupt-anew-matchbook-disk-student", + ) + val authRequest2 = AuthRequest( + id = "3", + publicKey = "publicKey-3", + platform = "Android", + ipAddress = "192.168.0.3", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.now(), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "pantry-overdue-survive-sleep-jab", + ) coEvery { authRepository.getAuthRequests() } returns AuthRequestsResult.Success( - authRequests = emptyList(), + authRequests = listOf( + authRequest1, + authRequest2, + ), + ) + coEvery { + authRepository.updateAuthRequest( + requestId = "2", + masterPasswordHash = "verySecureHash", + publicKey = "publicKey-2", + isApproved = false, + ) + } returns AuthRequestResult.Success( + authRequest1.copy( + responseDate = ZonedDateTime.now(), + ), + ) + coEvery { + authRepository.updateAuthRequest( + requestId = "3", + masterPasswordHash = "verySecureHash", + publicKey = "publicKey-3", + isApproved = false, + ) + } returns AuthRequestResult.Success( + authRequest2.copy( + responseDate = ZonedDateTime.now(), + ), ) val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.actionChannel.trySend(PendingRequestsAction.DeclineAllRequestsClick) - assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) - assertEquals( - PendingRequestsEvent.ShowToast("Not yet implemented.".asText()), - awaitItem(), + viewModel.actionChannel.trySend(PendingRequestsAction.DeclineAllRequestsConfirm) + + coVerify { + authRepository.updateAuthRequest( + requestId = "2", + masterPasswordHash = "verySecureHash", + publicKey = "publicKey-2", + isApproved = false, + ) + authRepository.updateAuthRequest( + requestId = "3", + masterPasswordHash = "verySecureHash", + publicKey = "publicKey-3", + isApproved = false, ) } } @@ -180,6 +271,53 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { @Suppress("LongMethod") @Test fun `on LifecycleResume should update state`() = runTest { + val dateTimeFormatter = DateTimeFormatter + .ofPattern("M/d/yy hh:mm a") + .withZone(TimeZone.getDefault().toZoneId()) + val nowZonedDateTime = ZonedDateTime.now() + val fiveMinZonedDateTime = ZonedDateTime.now().minusMinutes(5) + val sixMinZonedDateTime = ZonedDateTime.now().minusMinutes(6) + val requestList = listOf( + AuthRequest( + id = "1", + publicKey = "publicKey-1", + platform = "Android", + ipAddress = "192.168.0.1", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = sixMinZonedDateTime, + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = "fingerprint", + ), + AuthRequest( + id = "2", + publicKey = "publicKey-2", + platform = "iOS", + ipAddress = "192.168.0.2", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = fiveMinZonedDateTime, + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "erupt-anew-matchbook-disk-student", + ), + AuthRequest( + id = "3", + publicKey = "publicKey-3", + platform = "Android", + ipAddress = "192.168.0.3", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = nowZonedDateTime, + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "pantry-overdue-survive-sleep-jab", + ), + ) coEvery { authRepository.getAuthRequests() } returns AuthRequestsResult.Success(emptyList()) @@ -193,47 +331,21 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { coEvery { authRepository.getAuthRequests() } returns AuthRequestsResult.Success( - authRequests = listOf( - AuthRequest( - id = "1", - publicKey = "publicKey-1", - platform = "Android", - ipAddress = "192.168.0.1", - key = "publicKey", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2023-08-24T17:11Z"), - responseDate = null, - requestApproved = true, - originUrl = "www.bitwarden.com", - fingerprint = "pantry-overdue-survive-sleep-jab", - ), - AuthRequest( - id = "2", - publicKey = "publicKey-2", - platform = "iOS", - ipAddress = "192.168.0.2", - key = "publicKey", - masterPasswordHash = "verySecureHash", - creationDate = ZonedDateTime.parse("2023-08-21T15:43Z"), - responseDate = null, - requestApproved = false, - originUrl = "www.bitwarden.com", - fingerprint = "erupt-anew-matchbook-disk-student", - ), - ), + authRequests = requestList, ) val expected = DEFAULT_STATE.copy( + authRequests = requestList, viewState = PendingRequestsState.ViewState.Content( requests = listOf( PendingRequestsState.ViewState.Content.PendingLoginRequest( fingerprintPhrase = "pantry-overdue-survive-sleep-jab", platform = "Android", - timestamp = "8/24/23 05:11 PM", + timestamp = nowZonedDateTime.format(dateTimeFormatter), ), PendingRequestsState.ViewState.Content.PendingLoginRequest( fingerprintPhrase = "erupt-anew-matchbook-disk-student", platform = "iOS", - timestamp = "8/21/23 03:43 PM", + timestamp = fiveMinZonedDateTime.format(dateTimeFormatter), ), ), ), @@ -255,6 +367,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { companion object { val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( + authRequests = emptyList(), viewState = PendingRequestsState.ViewState.Empty, ) }