BIT-811 BIT-1566: Add Login Approval screen & hook up to Pending Requests (#808)

This commit is contained in:
Caleb Derosier 2024-01-27 09:54:30 -07:00 committed by Álison Fernandes
parent b991acd0d0
commit ab5a35b914
14 changed files with 929 additions and 3 deletions

View file

@ -178,6 +178,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
suspend fun createAuthRequest(email: String): AuthRequestResult
/**
* Get an auth request by its [fingerprint].
*/
suspend fun getAuthRequest(fingerprint: String): AuthRequestResult
/**
* Get a list of the current user's [AuthRequest]s.
*/

View file

@ -615,6 +615,21 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
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 }
request
?.let { AuthRequestResult.Success(it) }
?: AuthRequestResult.Error
}
}
override suspend fun getAuthRequests(): AuthRequestsResult =
authRequestsService
.getAuthRequests()

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val FINGERPRINT: String = "fingerprint"
private const val LOGIN_APPROVAL_PREFIX = "login_approval"
private const val LOGIN_APPROVAL_ROUTE = "$LOGIN_APPROVAL_PREFIX/{$FINGERPRINT}"
/**
* Class to retrieve login approval arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class LoginApprovalArgs(val fingerprint: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[FINGERPRINT]) as String,
)
}
/**
* Add login approval destinations to the nav graph.
*/
fun NavGraphBuilder.loginApprovalDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = LOGIN_APPROVAL_ROUTE,
) {
LoginApprovalScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Login Approval screen.
*/
fun NavController.navigateToLoginApproval(
fingerprint: String,
navOptions: NavOptions? = null,
) {
navigate("$LOGIN_APPROVAL_PREFIX/$fingerprint", navOptions)
}

View file

@ -0,0 +1,245 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
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.theme.LocalNonMaterialColors
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
/**
* Displays the login approval screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun LoginApprovalScreen(
viewModel: LoginApprovalViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LoginApprovalEvent.NavigateBack -> onNavigateBack()
is LoginApprovalEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.log_in_requested),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(LoginApprovalAction.CloseClick) }
},
)
},
) { innerPadding ->
when (val viewState = state.viewState) {
is LoginApprovalState.ViewState.Content -> {
LoginApprovalContent(
state = viewState,
onConfirmLoginClick = remember(viewModel) {
{ viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) }
},
onDeclineLoginClick = remember(viewModel) {
{ viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) }
},
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is LoginApprovalState.ViewState.Error -> {
BitwardenErrorContent(
message = stringResource(id = R.string.generic_error_message),
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is LoginApprovalState.ViewState.Loading -> {
BitwardenLoadingContent(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
}
@Suppress("LongMethod")
@Composable
private fun LoginApprovalContent(
state: LoginApprovalState.ViewState.Content,
onConfirmLoginClick: () -> Unit,
onDeclineLoginClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(state = rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.are_you_trying_to_log_in),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(
id = R.string.log_in_attempt_by_x_on_y,
state.email,
state.domainUrl,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.fingerprint_phrase),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.fingerprint,
textAlign = TextAlign.Start,
color = LocalNonMaterialColors.current.fingerprint,
style = LocalNonMaterialTypography.current.sensitiveInfoSmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
LoginApprovalInfoColumn(
label = stringResource(id = R.string.device_type),
value = state.deviceType,
)
LoginApprovalInfoColumn(
label = stringResource(id = R.string.ip_address),
value = state.ipAddress,
)
LoginApprovalInfoColumn(
label = stringResource(id = R.string.time),
value = state.time,
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.confirm_log_in),
onClick = onConfirmLoginClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.deny_log_in),
onClick = onDeclineLoginClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
/**
* A view displaying information about this login approval request.
*/
@Composable
private fun LoginApprovalInfoColumn(
label: String,
value: String,
) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
}

View file

@ -0,0 +1,190 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
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.AuthRequestResult
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.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.format.DateTimeFormatter
import java.util.TimeZone
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the login approval screen.
*/
@HiltViewModel
class LoginApprovalViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginApprovalState, LoginApprovalEvent, LoginApprovalAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginApprovalState(
fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint,
viewState = LoginApprovalState.ViewState.Loading,
),
) {
private val dateTimeFormatter
get() = DateTimeFormatter
.ofPattern("M/d/yy hh:mm a")
.withZone(TimeZone.getDefault().toZoneId())
init {
viewModelScope.launch {
trySendAction(
LoginApprovalAction.Internal.AuthRequestResultReceive(
authRequestResult = authRepository.getAuthRequest(state.fingerprint),
),
)
}
}
override fun handleAction(action: LoginApprovalAction) {
when (action) {
LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked()
LoginApprovalAction.CloseClick -> handleCloseClicked()
LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked()
is LoginApprovalAction.Internal.AuthRequestResultReceive -> {
handleAuthRequestResultReceived(action)
}
}
}
private fun handleApproveRequestClicked() {
// TODO BIT-1565 implement approve login request
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleCloseClicked() {
sendEvent(LoginApprovalEvent.NavigateBack)
}
private fun handleDeclineRequestClicked() {
// TODO BIT-1565 implement decline login request
sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleAuthRequestResultReceived(
action: LoginApprovalAction.Internal.AuthRequestResultReceive,
) {
val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return
mutableStateFlow.update {
it.copy(
viewState = when (val result = action.authRequestResult) {
is AuthRequestResult.Success -> {
LoginApprovalState.ViewState.Content(
deviceType = result.authRequest.platform,
domainUrl = result.authRequest.originUrl,
email = email,
fingerprint = result.authRequest.fingerprint,
ipAddress = result.authRequest.ipAddress,
time = dateTimeFormatter.format(result.authRequest.creationDate),
)
}
is AuthRequestResult.Error -> LoginApprovalState.ViewState.Error
},
)
}
}
}
/**
* Models state for the Login Approval screen.
*/
@Parcelize
data class LoginApprovalState(
val fingerprint: String,
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the [LoginApprovalScreen].
*/
@Parcelize
sealed class ViewState : Parcelable {
/**
* Content state for the [LoginApprovalScreen].
*/
@Parcelize
data class Content(
val deviceType: String,
val domainUrl: String,
val email: String,
val fingerprint: String,
val ipAddress: String,
val time: String,
) : ViewState()
/**
* Represents a state where the [LoginApprovalScreen] is unable to display data due to an
* error retrieving it.
*/
@Parcelize
data object Error : ViewState()
/**
* Loading state for the [LoginApprovalScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState()
}
}
/**
* Models events for the Login Approval screen.
*/
sealed class LoginApprovalEvent {
/**
* Navigates back.
*/
data object NavigateBack : LoginApprovalEvent()
/**
* Displays the [message] in a toast.
*/
data class ShowToast(
val message: Text,
) : LoginApprovalEvent()
}
/**
* Models actions for the Login Approval screen.
*/
sealed class LoginApprovalAction {
/**
* The user has clicked the Confirm login button.
*/
data object ApproveRequestClick : LoginApprovalAction()
/**
* The user has clicked the close button.
*/
data object CloseClick : LoginApprovalAction()
/**
* The user has clicked the Decline login button.
*/
data object DeclineRequestClick : LoginApprovalAction()
/**
* Models action the view model could send itself.
*/
sealed class Internal : LoginApprovalAction() {
/**
* An auth request result has been received to populate the data on the screen.
*/
data class AuthRequestResultReceive(
val authRequestResult: AuthRequestResult,
) : Internal()
}
}

View file

@ -12,11 +12,15 @@ private const val PENDING_REQUESTS_ROUTE = "pending_requests"
*/
fun NavGraphBuilder.pendingRequestsDestination(
onNavigateBack: () -> Unit,
onNavigateToLoginApproval: (fingerprintPhrase: String) -> Unit,
) {
composableWithSlideTransitions(
route = PENDING_REQUESTS_ROUTE,
) {
PendingRequestsScreen(onNavigateBack = onNavigateBack)
PendingRequestsScreen(
onNavigateBack = onNavigateBack,
onNavigateToLoginApproval = onNavigateToLoginApproval,
)
}
}

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@ -51,6 +54,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
fun PendingRequestsScreen(
viewModel: PendingRequestsViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
onNavigateToLoginApproval: (fingerprint: String) -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
@ -58,6 +62,9 @@ fun PendingRequestsScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
PendingRequestsEvent.NavigateBack -> onNavigateBack()
is PendingRequestsEvent.NavigateToLoginApproval -> {
onNavigateToLoginApproval(event.fingerprint)
}
is PendingRequestsEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
@ -96,6 +103,13 @@ fun PendingRequestsScreen(
)
}
},
onNavigateToLoginApproval = remember(viewModel) {
{
viewModel.trySendAction(
PendingRequestsAction.PendingRequestRowClick(it),
)
}
},
)
}
@ -128,6 +142,7 @@ fun PendingRequestsScreen(
private fun PendingRequestsContent(
state: PendingRequestsState.ViewState.Content,
onDeclineAllRequestsClick: () -> Unit,
onNavigateToLoginApproval: (fingerprint: String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -142,6 +157,7 @@ private fun PendingRequestsContent(
fingerprintPhrase = request.fingerprintPhrase,
platform = request.platform,
timestamp = request.timestamp,
onNavigateToLoginApproval = onNavigateToLoginApproval,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(
@ -171,10 +187,16 @@ private fun PendingRequestItem(
fingerprintPhrase: String,
platform: String,
timestamp: String,
onNavigateToLoginApproval: (fingerprintPhrase: String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
modifier = modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onNavigateToLoginApproval(fingerprintPhrase) },
),
horizontalAlignment = Alignment.Start,
) {
Spacer(modifier = Modifier.height(16.dp))

View file

@ -49,6 +49,9 @@ class PendingRequestsViewModel @Inject constructor(
when (action) {
PendingRequestsAction.CloseClick -> handleCloseClicked()
PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked()
is PendingRequestsAction.PendingRequestRowClick -> {
handlePendingRequestRowClicked(action)
}
is PendingRequestsAction.Internal.AuthRequestsResultReceive -> {
handleAuthRequestsResultReceived(action)
@ -64,6 +67,12 @@ class PendingRequestsViewModel @Inject constructor(
sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handlePendingRequestRowClicked(
action: PendingRequestsAction.PendingRequestRowClick,
) {
sendEvent(PendingRequestsEvent.NavigateToLoginApproval(action.fingerprint))
}
private fun handleAuthRequestsResultReceived(
action: PendingRequestsAction.Internal.AuthRequestsResultReceive,
) {
@ -156,6 +165,13 @@ sealed class PendingRequestsEvent {
*/
data object NavigateBack : PendingRequestsEvent()
/**
* Navigates to the Login Approval screen with the given fingerprint.
*/
data class NavigateToLoginApproval(
val fingerprint: String,
) : PendingRequestsEvent()
/**
* Displays the [message] in a toast.
*/
@ -179,6 +195,13 @@ sealed class PendingRequestsAction {
*/
data object DeclineAllRequestsClick : PendingRequestsAction()
/**
* The user has clicked one of the pending request rows.
*/
data class PendingRequestRowClick(
val fingerprint: String,
) : PendingRequestsAction()
/**
* Models actions sent by the view model itself.
*/

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.loginApprovalDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.exportVaultDestination
@ -79,7 +81,11 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() },
)
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
pendingRequestsDestination(onNavigateBack = { navController.popBackStack() })
loginApprovalDestination(onNavigateBack = { navController.popBackStack() })
pendingRequestsDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToLoginApproval = { navController.navigateToLoginApproval(it) },
)
vaultAddEditDestination(
onNavigateToQrCodeScanScreen = {
navController.navigateToQrCodeScanScreen()

View file

@ -2172,6 +2172,94 @@ class AuthRepositoryTest {
assertEquals(expected, result)
}
@Test
fun `getAuthRequest should return failure when getAuthRequests returns failure`() = runTest {
val fingerprint = "fingerprint"
coEvery {
authRequestsService.getAuthRequests()
} returns Throwable("Fail").asFailure()
val result = repository.getAuthRequest(fingerprint)
coVerify(exactly = 1) {
authRequestsService.getAuthRequests()
}
assertEquals(AuthRequestResult.Error, result)
}
@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,
)
} returns Result.success(fingerprint)
coEvery {
authRequestsService.getAuthRequests()
} returns responseJson.asSuccess()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
val result = repository.getAuthRequest(fingerprint)
coVerify(exactly = 1) {
authRequestsService.getAuthRequests()
authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY)
}
assertEquals(expected, result)
}
@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()
val result = repository.getAuthRequest(fingerprint)
coVerify(exactly = 1) {
authRequestsService.getAuthRequests()
}
assertEquals(expected, result)
}
@Test
fun `getAuthRequests should return failure when service returns failure`() = runTest {
coEvery {

View file

@ -0,0 +1,100 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
import androidx.compose.ui.test.onNodeWithText
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 io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
class LoginApprovalScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<LoginApprovalEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LoginApprovalViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
LoginApprovalScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(LoginApprovalEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `on Confirm login should send ApproveRequestClick`() = runTest {
// Set to content state to show appropriate buttons
mutableStateFlow.tryEmit(
LoginApprovalState(
fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android",
domainUrl = "bitwarden.com",
email = "test@bitwarden.com",
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "now",
),
),
)
composeTestRule
.onNodeWithText("Confirm login")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
}
}
@Test
fun `on Deny login should send DeclineRequestClick`() = runTest {
// Set to content state to show appropriate buttons
mutableStateFlow.tryEmit(
LoginApprovalState(
fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android",
domainUrl = "bitwarden.com",
email = "test@bitwarden.com",
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "now",
),
),
)
composeTestRule
.onNodeWithText("Deny login")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
}
}
}
private const val FINGERPRINT = "fingerprint"
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Loading,
)

View file

@ -0,0 +1,156 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval
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.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
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.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
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.util.TimeZone
class LoginApprovalViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val mockAuthRepository = mockk<AuthRepository> {
coEvery {
getAuthRequest(FINGERPRINT)
} returns AuthRequestResult.Success(AUTH_REQUEST)
every { userStateFlow } returns mutableUserStateFlow
}
@BeforeEach
fun setup() {
// Setting the timezone so the tests pass consistently no matter the environment.
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@AfterEach
fun tearDown() {
// Clearing the timezone after the test.
TimeZone.setDefault(null)
}
@Test
fun `initial state should be correct and trigger a getAuthRequest call`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
coVerify {
mockAuthRepository.getAuthRequest(FINGERPRINT)
}
verify {
mockAuthRepository.userStateFlow
}
}
@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)
assertEquals(expected, viewModel.stateFlow.value)
}
@Test
fun `on CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LoginApprovalAction.CloseClick)
assertEquals(LoginApprovalEvent.NavigateBack, awaitItem())
}
}
@Test
fun `on ApproveRequestClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `on DeclineRequestClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick)
assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
private fun createViewModel(
authRepository: AuthRepository = mockAuthRepository,
state: LoginApprovalState? = DEFAULT_STATE,
): LoginApprovalViewModel = LoginApprovalViewModel(
authRepository = authRepository,
savedStateHandle = SavedStateHandle()
.also { it["fingerprint"] = FINGERPRINT }
.apply { set("state", state) },
)
}
private const val EMAIL = "test@bitwarden.com"
private const val FINGERPRINT = "fingerprint"
private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState(
fingerprint = FINGERPRINT,
viewState = LoginApprovalState.ViewState.Content(
deviceType = "Android",
domainUrl = "www.bitwarden.com",
email = EMAIL,
fingerprint = FINGERPRINT,
ipAddress = "1.0.0.1",
time = "9/13/24 12:00 AM",
),
)
private const val USER_ID = "userID"
private val DEFAULT_USER_STATE = UserState(
activeUserId = USER_ID,
accounts = listOf(
UserState.Account(
userId = USER_ID,
name = "Active User",
email = EMAIL,
environment = Environment.Us,
avatarColorHex = "#aa00aa",
isBiometricsEnabled = false,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
organizations = emptyList(),
),
),
)
private val AUTH_REQUEST = AuthRequest(
id = "1",
publicKey = "2",
platform = "Android",
ipAddress = "1.0.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
fingerprint = FINGERPRINT,
)

View file

@ -16,6 +16,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
class PendingRequestsScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToLoginApprovalCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<PendingRequestsEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -29,6 +30,7 @@ class PendingRequestsScreenTest : BaseComposeTest() {
composeTestRule.setContent {
PendingRequestsScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToLoginApproval = { _ -> onNavigateToLoginApprovalCalled = true },
viewModel = viewModel,
)
}
@ -40,6 +42,12 @@ class PendingRequestsScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
fun `on NavigateToLoginApproval should call onNavigateToLoginApproval`() = runTest {
mutableEventFlow.tryEmit(PendingRequestsEvent.NavigateToLoginApproval("fingerprint"))
assertTrue(onNavigateToLoginApprovalCalled)
}
@Test
fun `on DeclineAllRequestsClick should send DeclineAllRequestsClick`() = runTest {
// set content so the Decline all requests button appears

View file

@ -142,6 +142,23 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on PendingRequestRowClick should emit NavigateToLoginApproval`() = runTest {
val fingerprint = "fingerprint"
coEvery {
authRepository.getAuthRequests()
} returns AuthRequestsResult.Success(
authRequests = emptyList(),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
PendingRequestsAction.PendingRequestRowClick(fingerprint),
)
assertEquals(PendingRequestsEvent.NavigateToLoginApproval(fingerprint), awaitItem())
}
}
@Test
fun `on DeclineAllRequestsClick should send ShowToast event`() = runTest {
coEvery {