PM-19233: Propagate auth request errors to the UI (#4868)

This commit is contained in:
David Perez
2025-03-14 13:33:39 -05:00
committed by GitHub
parent 6fe9eba620
commit 18ce45e7e5
12 changed files with 92 additions and 58 deletions

View File

@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.AuthRequestsUpdatesResult
import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult
import com.x8bit.bitwarden.data.auth.manager.util.isSso
import com.x8bit.bitwarden.data.auth.manager.util.toAuthRequestTypeJson
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
@@ -51,7 +52,9 @@ class AuthRequestManagerImpl(
override fun getAuthRequestsWithUpdates(): Flow<AuthRequestsUpdatesResult> = flow {
while (currentCoroutineContext().isActive) {
when (val result = getAuthRequests()) {
AuthRequestsResult.Error -> emit(AuthRequestsUpdatesResult.Error)
is AuthRequestsResult.Error -> {
emit(AuthRequestsUpdatesResult.Error(error = result.error))
}
is AuthRequestsResult.Success -> {
emit(AuthRequestsUpdatesResult.Update(authRequests = result.authRequests))
@@ -181,7 +184,7 @@ class AuthRequestManagerImpl(
)
}
.fold(
onFailure = { emit(AuthRequestUpdatesResult.Error) },
onFailure = { emit(AuthRequestUpdatesResult.Error(error = it)) },
onSuccess = { updateAuthRequest ->
when {
updateAuthRequest.requestApproved -> {
@@ -217,13 +220,18 @@ class AuthRequestManagerImpl(
fingerprint: String,
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
when (val authRequestsResult = getAuthRequests()) {
AuthRequestsResult.Error -> AuthRequestUpdatesResult.Error
is AuthRequestsResult.Error -> {
AuthRequestUpdatesResult.Error(error = authRequestsResult.error)
}
is AuthRequestsResult.Success -> {
authRequestsResult
.authRequests
.firstOrNull { it.fingerprint == fingerprint }
?.let { AuthRequestUpdatesResult.Update(it) }
?: AuthRequestUpdatesResult.Error
?: AuthRequestUpdatesResult.Error(
error = IllegalStateException("Could not find the auth request."),
)
}
}
}
@@ -233,30 +241,28 @@ class AuthRequestManagerImpl(
): Flow<AuthRequestUpdatesResult> = getAuthRequest {
authRequestsService
.getAuthRequest(requestId)
.map { response ->
getFingerprintPhrase(response.publicKey).getOrNull()?.let { fingerprint ->
AuthRequest(
id = response.id,
publicKey = response.publicKey,
platform = response.platform,
ipAddress = response.ipAddress,
key = response.key,
masterPasswordHash = response.masterPasswordHash,
creationDate = response.creationDate,
responseDate = response.responseDate,
requestApproved = response.requestApproved ?: false,
originUrl = response.originUrl,
fingerprint = fingerprint,
)
}
.mapCatching { response ->
getFingerprintPhrase(response.publicKey)
.getOrThrow()
.let { fingerprint ->
AuthRequest(
id = response.id,
publicKey = response.publicKey,
platform = response.platform,
ipAddress = response.ipAddress,
key = response.key,
masterPasswordHash = response.masterPasswordHash,
creationDate = response.creationDate,
responseDate = response.responseDate,
requestApproved = response.requestApproved ?: false,
originUrl = response.originUrl,
fingerprint = fingerprint,
)
}
}
.fold(
onFailure = { AuthRequestUpdatesResult.Error },
onSuccess = { authRequest ->
authRequest
?.let { AuthRequestUpdatesResult.Update(it) }
?: AuthRequestUpdatesResult.Error
},
onFailure = { AuthRequestUpdatesResult.Error(error = it) },
onSuccess = { AuthRequestUpdatesResult.Update(authRequest = it) },
)
}
@@ -308,7 +314,7 @@ class AuthRequestManagerImpl(
}
}
.fold(
onFailure = { AuthRequestsResult.Error },
onFailure = { AuthRequestsResult.Error(error = it) },
onSuccess = { AuthRequestsResult.Success(authRequests = it) },
)
@@ -318,7 +324,7 @@ class AuthRequestManagerImpl(
publicKey: String,
isApproved: Boolean,
): AuthRequestResult {
val userId = activeUserId ?: return AuthRequestResult.Error
val userId = activeUserId ?: return AuthRequestResult.Error(error = NoActiveUserException())
return vaultSdkSource
.getAuthRequestKey(
publicKey = publicKey,
@@ -349,7 +355,7 @@ class AuthRequestManagerImpl(
)
}
.fold(
onFailure = { AuthRequestResult.Error },
onFailure = { AuthRequestResult.Error(error = it) },
onSuccess = { AuthRequestResult.Success(authRequest = it) },
)
}
@@ -461,7 +467,7 @@ class AuthRequestManagerImpl(
publicKey: String,
): Result<String> {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return IllegalStateException("No active account").asFailure()
?: return NoActiveUserException().asFailure()
return authSdkSource.getUserFingerprint(
email = profile.email,
publicKey = publicKey,

View File

@@ -14,5 +14,7 @@ sealed class AuthRequestResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestResult()
data class Error(
val error: Throwable,
) : AuthRequestResult()
}

View File

@@ -19,7 +19,9 @@ sealed class AuthRequestUpdatesResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestUpdatesResult()
data class Error(
val error: Throwable,
) : AuthRequestUpdatesResult()
/**
* The auth request has been declined.

View File

@@ -14,5 +14,7 @@ sealed class AuthRequestsResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestsResult()
data class Error(
val error: Throwable,
) : AuthRequestsResult()
}

View File

@@ -14,5 +14,7 @@ sealed class AuthRequestsUpdatesResult {
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestsUpdatesResult()
data class Error(
val error: Throwable,
) : AuthRequestsUpdatesResult()
}

View File

@@ -0,0 +1,6 @@
package com.x8bit.bitwarden.data.platform.error
/**
* An exception indicating that there is currently no active user when one is required.
*/
class NoActiveUserException : IllegalStateException("No current active user!")

View File

@@ -296,6 +296,7 @@ private fun LoginApprovalDialogs(
is LoginApprovalState.DialogState.Error -> BitwardenBasicDialog(
title = state.title?.invoke(),
message = state.message(),
throwable = state.error,
onDismissRequest = onDismissError,
)

View File

@@ -177,7 +177,7 @@ class LoginApprovalViewModel @Inject constructor(
private fun handleApproveRequestResultReceived(
action: LoginApprovalAction.Internal.ApproveRequestResultReceive,
) {
when (action.result) {
when (val result = action.result) {
is AuthRequestResult.Success -> {
sendEvent(LoginApprovalEvent.ShowToast(R.string.login_approved.asText()))
sendClosingEvent()
@@ -189,6 +189,7 @@ class LoginApprovalViewModel @Inject constructor(
dialogState = LoginApprovalState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
error = result.error,
),
)
}
@@ -239,7 +240,7 @@ class LoginApprovalViewModel @Inject constructor(
private fun handleDeclineRequestResultReceived(
action: LoginApprovalAction.Internal.DeclineRequestResultReceive,
) {
when (action.result) {
when (val result = action.result) {
is AuthRequestResult.Success -> {
sendEvent(LoginApprovalEvent.ShowToast(R.string.log_in_denied.asText()))
sendClosingEvent()
@@ -251,6 +252,7 @@ class LoginApprovalViewModel @Inject constructor(
dialogState = LoginApprovalState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
error = result.error,
),
)
}
@@ -327,6 +329,7 @@ data class LoginApprovalState(
data class Error(
val title: Text?,
val message: Text,
val error: Throwable? = null,
) : DialogState()
/**

View File

@@ -169,7 +169,7 @@ class PendingRequestsViewModel @Inject constructor(
}
}
AuthRequestsUpdatesResult.Error -> {
is AuthRequestsUpdatesResult.Error -> {
mutableStateFlow.update {
it.copy(
authRequests = emptyList(),

View File

@@ -427,12 +427,13 @@ class AuthRequestManagerTest {
fun `getAuthRequestByFingerprintFlow should emit failure and cancel flow when getAuthRequests fails`() =
runTest {
val fingerprint = "fingerprint"
coEvery { authRequestsService.getAuthRequests() } returns Throwable("Fail").asFailure()
val error = Throwable("Fail")
coEvery { authRequestsService.getAuthRequests() } returns error.asFailure()
repository
.getAuthRequestByFingerprintFlow(fingerprint)
.test {
assertEquals(AuthRequestUpdatesResult.Error, awaitItem())
assertEquals(AuthRequestUpdatesResult.Error(error = error), awaitItem())
awaitComplete()
}
@@ -449,8 +450,9 @@ class AuthRequestManagerTest {
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
)
val authRequest = AUTH_REQUEST
val error = Throwable("Fail")
val expectedOne = AuthRequestUpdatesResult.Update(authRequest = authRequest)
val expectedTwo = AuthRequestUpdatesResult.Error
val expectedTwo = AuthRequestUpdatesResult.Error(error = error)
coEvery {
authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY)
} returns FINGER_PRINT.asSuccess()
@@ -459,7 +461,7 @@ class AuthRequestManagerTest {
} returns authRequestsResponseJson.asSuccess()
coEvery {
authRequestsService.getAuthRequest(requestId = REQUEST_ID)
} returns Throwable("Fail").asFailure()
} returns error.asFailure()
fakeAuthDiskSource.userState = SINGLE_USER_STATE
repository
@@ -639,14 +641,13 @@ class AuthRequestManagerTest {
@Test
fun `getAuthRequestByIdFlow should emit failure and cancel flow when getAuthRequests fails`() =
runTest {
coEvery {
authRequestsService.getAuthRequest(REQUEST_ID)
} returns Throwable("Fail").asFailure()
val error = Throwable("Fail")
coEvery { authRequestsService.getAuthRequest(REQUEST_ID) } returns error.asFailure()
repository
.getAuthRequestByIdFlow(REQUEST_ID)
.test {
assertEquals(AuthRequestUpdatesResult.Error, awaitItem())
assertEquals(AuthRequestUpdatesResult.Error(error = error), awaitItem())
awaitComplete()
}
@@ -660,10 +661,11 @@ class AuthRequestManagerTest {
fun `getAuthRequestByIdFlow should emit update then not cancel on failure when initial request succeeds and second fails`() =
runTest {
val authRequestResponseOne = AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE.asSuccess()
val authRequestResponseTwo = Throwable("Fail").asFailure()
val error = Throwable("Fail")
val authRequestResponseTwo = error.asFailure()
val authRequest = AUTH_REQUEST.copy(id = REQUEST_ID)
val expectedOne = AuthRequestUpdatesResult.Update(authRequest = authRequest)
val expectedTwo = AuthRequestUpdatesResult.Error
val expectedTwo = AuthRequestUpdatesResult.Error(error = error)
coEvery {
authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY)
} returns FINGER_PRINT.asSuccess()
@@ -850,11 +852,12 @@ class AuthRequestManagerTest {
val authRequestsResponseJson = AuthRequestsResponseJson(
authRequests = listOf(AUTH_REQUESTS_RESPONSE_JSON_AUTH_RESPONSE),
)
val expectedOne = AuthRequestsUpdatesResult.Error
val error = Throwable("Fail")
val expectedOne = AuthRequestsUpdatesResult.Error(error = error)
val expectedTwo = AuthRequestsUpdatesResult.Update(authRequests = authRequests)
coEvery {
authRequestsService.getAuthRequests()
} returns Throwable("Fail").asFailure() andThen authRequestsResponseJson.asSuccess()
} returns error.asFailure() andThen authRequestsResponseJson.asSuccess()
coEvery {
authSdkSource.getUserFingerprint(email = EMAIL, publicKey = PUBLIC_KEY)
} returns FINGER_PRINT.asSuccess()
@@ -935,14 +938,15 @@ class AuthRequestManagerTest {
@Test
fun `getAuthRequests should return failure when service returns failure`() = runTest {
coEvery { authRequestsService.getAuthRequests() } returns Throwable("Fail").asFailure()
val error = Throwable("Fail")
coEvery { authRequestsService.getAuthRequests() } returns error.asFailure()
val result = repository.getAuthRequests()
coVerify(exactly = 1) {
authRequestsService.getAuthRequests()
}
assertEquals(AuthRequestsResult.Error, result)
assertEquals(AuthRequestsResult.Error(error = error), result)
}
@Test
@@ -1027,9 +1031,10 @@ class AuthRequestManagerTest {
@Test
fun `updateAuthRequest should return failure when sdk returns failure`() = runTest {
val error = Throwable("Fail")
coEvery {
vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID)
} returns Throwable("Fail").asFailure()
} returns error.asFailure()
fakeAuthDiskSource.userState = SINGLE_USER_STATE
val result = repository.updateAuthRequest(
@@ -1042,7 +1047,7 @@ class AuthRequestManagerTest {
coVerify(exactly = 1) {
vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID)
}
assertEquals(AuthRequestResult.Error, result)
assertEquals(AuthRequestResult.Error(error = error), result)
}
@Test
@@ -1050,6 +1055,7 @@ class AuthRequestManagerTest {
val requestId = "requestId"
val passwordHash = "masterPasswordHash"
val encodedKey = "encodedKey"
val error = Throwable("Mission failed")
coEvery {
vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID)
} returns encodedKey.asSuccess()
@@ -1061,7 +1067,7 @@ class AuthRequestManagerTest {
deviceId = UNIQUE_APP_ID,
isApproved = false,
)
} returns Throwable("Mission failed").asFailure()
} returns error.asFailure()
fakeAuthDiskSource.userState = SINGLE_USER_STATE
val result = repository.updateAuthRequest(
@@ -1074,7 +1080,7 @@ class AuthRequestManagerTest {
coVerify(exactly = 1) {
vaultSdkSource.getAuthRequestKey(publicKey = PUBLIC_KEY, userId = USER_ID)
}
assertEquals(AuthRequestResult.Error, result)
assertEquals(AuthRequestResult.Error(error = error), result)
}
@Test

View File

@@ -210,7 +210,7 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
viewState = LoginApprovalState.ViewState.Error,
)
val viewModel = createViewModel()
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Error)
mutableAuthRequestSharedFlow.tryEmit(AuthRequestUpdatesResult.Error(error = Throwable()))
assertEquals(expected, viewModel.stateFlow.value)
}
@@ -383,6 +383,7 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
@Test
fun `on ErrorDialogDismiss should update state`() = runTest {
val viewModel = createViewModel()
val error = Throwable("Fail!")
coEvery {
mockAuthRepository.updateAuthRequest(
requestId = REQUEST_ID,
@@ -390,7 +391,7 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
publicKey = PUBLIC_KEY,
isApproved = false,
)
} returns AuthRequestResult.Error
} returns AuthRequestResult.Error(error = error)
viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick)
assertEquals(
@@ -399,6 +400,7 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
dialogState = LoginApprovalState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
error = error,
),
),
)

View File

@@ -152,7 +152,9 @@ class PendingRequestsViewModelTest : BaseViewModelTest() {
viewState = PendingRequestsState.ViewState.Error,
)
val viewModel = createViewModel()
mutableAuthRequestsWithUpdatesFlow.tryEmit(AuthRequestsUpdatesResult.Error)
mutableAuthRequestsWithUpdatesFlow.tryEmit(
value = AuthRequestsUpdatesResult.Error(error = Throwable()),
)
assertEquals(expected, viewModel.stateFlow.value)
}