diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt new file mode 100644 index 0000000000..12d9b028c2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.api + +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson +import retrofit2.http.GET + +/** + * Defines raw calls under the /auth-requests API. + */ +interface AuthRequestsApi { + + /** + * Gets a list of auth requests for this device. + */ + @GET("/auth-requests") + suspend fun getAuthRequests(): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt index 569efc77bf..0837ada4c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/AuthNetworkModule.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.di import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsServiceImpl import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesServiceImpl import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService @@ -35,6 +37,14 @@ object AuthNetworkModule { json = json, ) + @Provides + @Singleton + fun providesAuthRequestsService( + retrofits: Retrofits, + ): AuthRequestsService = AuthRequestsServiceImpl( + authRequestsApi = retrofits.authenticatedApiRetrofit.create(), + ) + @Provides @Singleton fun providesDevicesService( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthRequestsResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthRequestsResponseJson.kt new file mode 100644 index 0000000000..1943a2fbd1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthRequestsResponseJson.kt @@ -0,0 +1,64 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.ZonedDateTime + +/** + * Response body for authentication requests used for Login with device. + * + * @property authRequests The list of auth requests. + */ +@Serializable +data class AuthRequestsResponseJson( + @SerialName("data") val authRequests: List, +) { + /** + * Response body for an authentication request. + * + * @param id The id of this auth request. + * @param publicKey The user's public key. + * @param platform The platform from which this request was sent. + * @param ipAddress The IP address of the device from which this request was sent. + * @param key The key of this auth request. + * @param masterPasswordHash The hash for this user's master password. + * @param creationDate The date & time on which this request was created. + * @param responseDate The date & time on which this request was responded to. + * @param requestApproved Whether this request was approved. + * @param originUrl The origin URL of this auth request. + */ + @Serializable + data class AuthRequest( + @SerialName("id") + val id: String, + + @SerialName("publicKey") + val publicKey: String, + + @SerialName("requestDeviceType") + val platform: String, + + @SerialName("requestIpAddress") + val ipAddress: String, + @SerialName("key") + val key: String?, + + @SerialName("masterPasswordHash") + val masterPasswordHash: String?, + + @SerialName("creationDate") + @Contextual + val creationDate: ZonedDateTime, + + @SerialName("responseDate") + @Contextual + val responseDate: ZonedDateTime?, + + @SerialName("requestApproved") + val requestApproved: Boolean?, + + @SerialName("origin") + val originUrl: String, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt new file mode 100644 index 0000000000..53815e13ca --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson + +/** + * Provides an API for interacting with login approval / authentication requests. + */ +interface AuthRequestsService { + /** + * Gets the list of auth requests for the current user. + */ + suspend fun getAuthRequests(): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt new file mode 100644 index 0000000000..403da2e971 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson + +class AuthRequestsServiceImpl( + private val authRequestsApi: AuthRequestsApi, +) : AuthRequestsService { + + override suspend fun getAuthRequests(): Result = + authRequestsApi.getAuthRequests() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 5da0632066..1eecad9d8a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository +import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest +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 import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult @@ -123,6 +125,11 @@ interface AuthRepository : AuthenticatorProvider { */ fun setSsoCallbackResult(result: SsoCallbackResult) + /** + * Get a list of the current user's [AuthRequest]s. + */ + suspend fun getAuthRequests(): AuthRequestsResult + /** * Get a [Boolean] indicating whether this is a known device. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index d2a70f8b4f..4b935ed342 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -12,12 +12,15 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenRespon import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource 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.AuthRequestsResult import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult @@ -65,6 +68,7 @@ import javax.inject.Singleton @Singleton class AuthRepositoryImpl( private val accountsService: AccountsService, + private val authRequestsService: AuthRequestsService, private val devicesService: DevicesService, private val haveIBeenPwnedService: HaveIBeenPwnedService, private val identityService: IdentityService, @@ -426,6 +430,30 @@ class AuthRepositoryImpl( mutableSsoCallbackResultFlow.tryEmit(result) } + override suspend fun getAuthRequests(): AuthRequestsResult = + authRequestsService.getAuthRequests() + .fold( + onFailure = { AuthRequestsResult.Error }, + onSuccess = { response -> + AuthRequestsResult.Success( + authRequests = response.authRequests.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, + ) + }, + ) + }, + ) + override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult = devicesService .getIsKnownDevice( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 7a8a6bcf4e..f01d8701ce 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService @@ -31,6 +32,7 @@ object AuthRepositoryModule { @Suppress("LongParameterList") fun providesAuthRepository( accountsService: AccountsService, + authRequestsService: AuthRequestsService, devicesService: DevicesService, identityService: IdentityService, haveIBeenPwnedService: HaveIBeenPwnedService, @@ -43,6 +45,7 @@ object AuthRepositoryModule { userLogoutManager: UserLogoutManager, ): AuthRepository = AuthRepositoryImpl( accountsService = accountsService, + authRequestsService = authRequestsService, devicesService = devicesService, identityService = identityService, authSdkSource = authSdkSource, 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 new file mode 100644 index 0000000000..4c19fb4ee4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequest.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import java.time.ZonedDateTime + +/** + * Represents a Login Approval request. + * + * @param id The id of this request. + * @param publicKey The user's public key. + * @param platform The platform from which this request was sent. + * @param ipAddress The IP address of the device from which this request was sent. + * @param key The key of this request. + * @param masterPasswordHash The hash for this user's master password. + * @param creationDate The date & time on which this request was created. + * @param responseDate The date & time on which this request was responded to. + * @param requestApproved Whether this request was approved. + * @param originUrl The origin URL of this auth request. + */ +data class AuthRequest( + val id: String, + val publicKey: String, + val platform: String, + val ipAddress: String, + val key: String?, + val masterPasswordHash: String?, + val creationDate: ZonedDateTime, + val responseDate: ZonedDateTime?, + val requestApproved: Boolean, + val originUrl: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsResult.kt new file mode 100644 index 0000000000..843d8cd4f6 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/AuthRequestsResult.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of getting the list of login approval requests for the current user. + */ +sealed class AuthRequestsResult { + /** + * Models the result of getting a user's auth requests. + */ + data class Success( + val authRequests: List, + ) : AuthRequestsResult() + + /** + * There was an error getting the user's auth requests. + */ + data object Error : AuthRequestsResult() +} 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 a481f7589c..9684287eb2 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 @@ -105,14 +105,14 @@ fun PendingRequestsScreen( .fillMaxSize(), ) - is PendingRequestsState.ViewState.Error -> BitwardenErrorContent( - message = viewState.message.toString(resources), + PendingRequestsState.ViewState.Error -> BitwardenErrorContent( + message = stringResource(R.string.generic_error_message), modifier = Modifier .padding(innerPadding) .fillMaxSize(), ) - is PendingRequestsState.ViewState.Loading -> BitwardenLoadingContent( + PendingRequestsState.ViewState.Loading -> BitwardenLoadingContent( modifier = Modifier .padding(innerPadding) .fillMaxSize(), 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 32bbfcf8ed..94ace4007d 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 @@ -2,11 +2,18 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending 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.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 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" @@ -16,20 +23,36 @@ private const val KEY_STATE = "state" */ @HiltViewModel class PendingRequestsViewModel @Inject constructor( + authRepository: AuthRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState( - viewState = PendingRequestsState.ViewState.Empty, + viewState = PendingRequestsState.ViewState.Loading, ), ) { + private val dateTimeFormatter + get() = DateTimeFormatter + .ofPattern("M/d/yy hh:mm a") + .withZone(TimeZone.getDefault().toZoneId()) + init { - // TODO BIT-1291: make /auth-requests call + viewModelScope.launch { + trySendAction( + PendingRequestsAction.Internal.AuthRequestsResultReceive( + authRequestsResult = authRepository.getAuthRequests(), + ), + ) + } } override fun handleAction(action: PendingRequestsAction) { when (action) { PendingRequestsAction.CloseClick -> handleCloseClicked() PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked() + + is PendingRequestsAction.Internal.AuthRequestsResultReceive -> { + handleAuthRequestsResultReceived(action) + } } } @@ -40,6 +63,36 @@ class PendingRequestsViewModel @Inject constructor( private fun handleDeclineAllRequestsClicked() { sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText())) } + + 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.publicKey, + platform = authRequest.platform, + timestamp = dateTimeFormatter.format( + authRequest.creationDate, + ), + ) + }, + ) + } + } + + AuthRequestsResult.Error -> PendingRequestsState.ViewState.Error + }, + ) + } + } } /** @@ -81,13 +134,9 @@ data class PendingRequestsState( /** * Represents a state where the [PendingRequestsScreen] is unable to display data due to an * error retrieving it. - * - * @property message The message to display on the error screen. */ @Parcelize - data class Error( - val message: Text, - ) : ViewState() + data object Error : ViewState() /** * Loading state for the [PendingRequestsScreen], signifying that the content is being @@ -129,4 +178,16 @@ sealed class PendingRequestsAction { * The user has clicked to deny all login requests. */ data object DeclineAllRequestsClick : PendingRequestsAction() + + /** + * Models actions sent by the view model itself. + */ + sealed class Internal : PendingRequestsAction() { + /** + * Indicates that a new auth requests result has been received. + */ + data class AuthRequestsResultReceive( + val authRequestsResult: AuthRequestsResult, + ) : Internal() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt new file mode 100644 index 0000000000..f0a46ba2de --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt @@ -0,0 +1,48 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import retrofit2.create + +class AuthRequestsServiceTest : BaseServiceTest() { + + private val authRequestsApi: AuthRequestsApi = retrofit.create() + private val service = AuthRequestsServiceImpl( + authRequestsApi = authRequestsApi, + ) + + @Test + fun `getAuthRequests when request response is Failure should return Failure`() = runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val actual = service.getAuthRequests() + assertTrue(actual.isFailure) + } + + @Test + fun `getAuthRequests when request response is Success should return Success`() = runTest { + val json = """ + { + "data": [ + { + "id": "1", + "publicKey": "2", + "requestDeviceType": "Android", + "requestIpAddress": "1.0.0.1", + "creationDate": "2024-09-13T01:00:00.00Z", + "requestApproved": true, + "origin": "www.bitwarden.com" + } + ] + } + """ + val response = MockResponse().setBody(json).setResponseCode(200) + server.enqueue(response) + val actual = service.getAuthRequests() + assertTrue(actual.isSuccess) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 73aff0fd0b..db26506b63 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson @@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenRespon import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService @@ -28,6 +30,8 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4 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.AuthRequestsResult import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult @@ -58,7 +62,6 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganiz import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult -import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -77,12 +80,14 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.ZonedDateTime @Suppress("LargeClass") class AuthRepositoryTest { private val dispatcherManager: DispatcherManager = FakeDispatcherManager() private val accountsService: AccountsService = mockk() + private val authRequestsService: AuthRequestsService = mockk() private val devicesService: DevicesService = mockk() private val identityService: IdentityService = mockk() private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk() @@ -135,6 +140,7 @@ class AuthRepositoryTest { private val repository = AuthRepositoryImpl( accountsService = accountsService, + authRequestsService = authRequestsService, devicesService = devicesService, identityService = identityService, haveIBeenPwnedService = haveIBeenPwnedService, @@ -150,7 +156,6 @@ class AuthRepositoryTest { @BeforeEach fun beforeEach() { - clearMocks(identityService, accountsService, haveIBeenPwnedService) mockkStatic( GetTokenResponseJson.Success::toUserState, RefreshTokenResponseJson::toUserStateJson, @@ -1235,6 +1240,66 @@ class AuthRepositoryTest { ) } + @Test + fun `getAuthRequests should return failure when service returns failure`() = runTest { + coEvery { + authRequestsService.getAuthRequests() + } returns Throwable("Fail").asFailure() + + val result = repository.getAuthRequests() + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + assertEquals(AuthRequestsResult.Error, result) + } + + @Test + fun `getAuthRequests should return success when service returns success`() = runTest { + val responseJson = AuthRequestsResponseJson( + authRequests = listOf( + AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = "2", + 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 = AuthRequestsResult.Success( + authRequests = listOf( + AuthRequest( + id = "1", + publicKey = "2", + 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", + ), + ), + ) + coEvery { + authRequestsService.getAuthRequests() + } returns responseJson.asSuccess() + + val result = repository.getAuthRequests() + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + assertEquals(expected, result) + } + @Test fun `getIsKnownDevice should return failure when service returns failure`() = runTest { coEvery { 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 93c6fb5d92..a248609161 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 @@ -69,7 +69,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { companion object { val DEFAULT_STATE: PendingRequestsState = PendingRequestsState( - viewState = PendingRequestsState.ViewState.Empty, + 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 58adf532dc..4ab41f9d24 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 @@ -2,31 +2,137 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending 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.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 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 PendingRequestsViewModelTest : BaseViewModelTest() { - @Test - fun `initial state should be correct when not set`() { - val viewModel = createViewModel(state = null) - assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + private val authRepository = mockk() + + @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 when set`() { - val state = DEFAULT_STATE.copy( - viewState = PendingRequestsState.ViewState.Loading, + fun `initial state should be correct and trigger a getAuthRequests call`() { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = emptyList(), ) - val viewModel = createViewModel(state = state) - assertEquals(state, viewModel.stateFlow.value) + val viewModel = createViewModel(state = null) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + coVerify { + authRepository.getAuthRequests() + } + } + + @Test + fun `getPendingResults success with content should update state`() { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = listOf( + AuthRequest( + id = "1", + publicKey = "pantry-overdue-survive-sleep-jab", + 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", + ), + AuthRequest( + id = "2", + publicKey = "erupt-anew-matchbook-disk-student", + 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", + ), + ), + ) + val expected = DEFAULT_STATE.copy( + 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", + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals(expected, viewModel.stateFlow.value) + } + + @Test + fun `getPendingResults success with empty list should update state`() { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = emptyList(), + ) + val expected = DEFAULT_STATE.copy( + viewState = PendingRequestsState.ViewState.Empty, + ) + val viewModel = createViewModel() + assertEquals(expected, viewModel.stateFlow.value) + } + + @Test + fun `getPendingResults failure with error should update state`() { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Error + val expected = DEFAULT_STATE.copy( + viewState = PendingRequestsState.ViewState.Error, + ) + val viewModel = createViewModel() + assertEquals(expected, viewModel.stateFlow.value) } @Test fun `on CloseClick should emit NavigateBack`() = runTest { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = emptyList(), + ) val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(PendingRequestsAction.CloseClick) @@ -36,6 +142,11 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { @Test fun `on DeclineAllRequestsClick should send ShowToast event`() = runTest { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = emptyList(), + ) val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.actionChannel.trySend(PendingRequestsAction.DeclineAllRequestsClick) @@ -50,6 +161,7 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { private fun createViewModel( state: PendingRequestsState? = DEFAULT_STATE, ): PendingRequestsViewModel = PendingRequestsViewModel( + authRepository = authRepository, savedStateHandle = SavedStateHandle().apply { set("state", state) }, )