PM-25908: Process 400 responses from verification code APIs (#5900)

This commit is contained in:
David Perez 2025-09-19 10:29:28 -05:00 committed by GitHub
parent b4a31764c4
commit 4f244c52fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 255 additions and 12 deletions

View File

@ -33,6 +33,8 @@ import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
import com.bitwarden.network.model.VerifyEmailTokenRequestJson
import com.bitwarden.network.model.VerifyEmailTokenResponseJson
import com.bitwarden.network.service.AccountsService
@ -740,7 +742,17 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
onSuccess = {
when (it) {
VerificationCodeResponseJson.Success -> ResendEmailResult.Success
is VerificationCodeResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
)
}
?: ResendEmailResult.Error(
@ -753,7 +765,17 @@ class AuthRepositoryImpl(
?.let { jsonRequest ->
accountsService.resendNewDeviceOtp(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message, error = it) },
onSuccess = { ResendEmailResult.Success },
onSuccess = {
when (it) {
VerificationOtpResponseJson.Success -> ResendEmailResult.Success
is VerificationOtpResponseJson.Invalid -> {
ResendEmailResult.Error(
message = it.firstValidationErrorMessage,
error = null,
)
}
}
},
)
}
?: ResendEmailResult.Error(

View File

@ -15,6 +15,6 @@ sealed class ResendEmailResult {
*/
data class Error(
val message: String?,
val error: Throwable,
val error: Throwable?,
) : ResendEmailResult()
}

View File

@ -407,7 +407,8 @@ class TwoFactorLoginViewModel @Inject constructor(
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.verification_email_not_sent.asText(),
message = result.message?.asText()
?: BitwardenString.verification_email_not_sent.asText(),
error = result.error,
),
)

View File

@ -48,6 +48,7 @@ import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerifiedOrganizationDomainSsoDetailsResponse
import com.bitwarden.network.model.VerifyEmailTokenRequestJson
import com.bitwarden.network.model.VerifyEmailTokenResponseJson
@ -5887,7 +5888,7 @@ class AuthRepositoryTest {
ssoToken = null,
),
)
} returns Unit.asSuccess()
} returns VerificationCodeResponseJson.Success.asSuccess()
val resendEmailResult = repository.resendVerificationCodeEmail()
assertEquals(ResendEmailResult.Success, resendEmailResult)
coVerify {
@ -5902,6 +5903,73 @@ class AuthRepositoryTest {
}
}
@Test
fun `resendVerificationCodeEmail uses cached request data to make api call with error`() =
runTest {
// Attempt a normal login with a two factor error first, so that the necessary
// data will be cached.
coEvery { identityService.preLogin(EMAIL) } returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
ssoToken = null,
twoFactorProviders = null,
)
.asSuccess()
val firstResult = repository.login(email = EMAIL, password = PASSWORD)
assertEquals(LoginResult.TwoFactorRequired, firstResult)
coVerify { identityService.preLogin(email = EMAIL) }
coVerify {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
uniqueAppId = UNIQUE_APP_ID,
)
}
// Resend the verification code email.
val message = "Failure Message"
coEvery {
accountsService.resendVerificationCodeEmail(
body = ResendEmailRequestJson(
deviceIdentifier = UNIQUE_APP_ID,
email = EMAIL,
passwordHash = PASSWORD_HASH,
ssoToken = null,
),
)
} returns VerificationCodeResponseJson
.Invalid(message = message, validationErrors = null)
.asSuccess()
val resendEmailResult = repository.resendVerificationCodeEmail()
assertEquals(
ResendEmailResult.Error(message = message, error = null),
resendEmailResult,
)
coVerify {
accountsService.resendVerificationCodeEmail(
body = ResendEmailRequestJson(
deviceIdentifier = UNIQUE_APP_ID,
email = EMAIL,
passwordHash = PASSWORD_HASH,
ssoToken = null,
),
)
}
}
@Test
fun `resendVerificationCodeEmail returns error if no request data cached`() = runTest {
val result = repository.resendVerificationCodeEmail()

View File

@ -21,8 +21,9 @@ sealed interface InvalidJsonResponse {
* Returns the first error message found in [validationErrors], or [message] if there are no
* [validationErrors] present.
*/
val firstValidationErrorMessage: String?
val firstValidationErrorMessage: String
get() = validationErrors
?.flatMap { it.value }
?.first()
?.firstOrNull()
?: message
}

View File

@ -0,0 +1,33 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models response bodies from verification code response.
*/
@Serializable
sealed class VerificationCodeResponseJson {
/**
* The success body of the verification code response.
*/
@Serializable
data object Success : VerificationCodeResponseJson()
/**
* Models the json body of verification code error.
*
* @param message a human readable error message.
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
override val message: String,
@SerialName("validationErrors")
override val validationErrors: Map<String, List<String>>?,
) : VerificationCodeResponseJson(), InvalidJsonResponse
}

View File

@ -0,0 +1,33 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models response bodies from verification OTP response.
*/
@Serializable
sealed class VerificationOtpResponseJson {
/**
* The success body of the verification OTP response.
*/
@Serializable
data object Success : VerificationOtpResponseJson()
/**
* Models the json body of verification OTP error.
*
* @param message a human readable error message.
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
override val message: String,
@SerialName("validationErrors")
override val validationErrors: Map<String, List<String>>?,
) : VerificationOtpResponseJson(), InvalidJsonResponse
}

View File

@ -8,6 +8,8 @@ import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
/**
* Provides an API for querying accounts endpoints.
@ -51,12 +53,16 @@ interface AccountsService {
/**
* Resend the email with the two-factor verification code.
*/
suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit>
suspend fun resendVerificationCodeEmail(
body: ResendEmailRequestJson,
): Result<VerificationCodeResponseJson>
/**
* Resend the email with the verification code for new devices
*/
suspend fun resendNewDeviceOtp(body: ResendNewDeviceOtpRequestJson): Result<Unit>
suspend fun resendNewDeviceOtp(
body: ResendNewDeviceOtpRequestJson,
): Result<VerificationOtpResponseJson>
/**
* Reset the password.

View File

@ -16,6 +16,8 @@ import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
import com.bitwarden.network.model.VerifyOtpRequestJson
import com.bitwarden.network.model.toBitwardenError
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
@ -111,15 +113,39 @@ internal class AccountsServiceImpl(
?: throw throwable
}
override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> =
override suspend fun resendVerificationCodeEmail(
body: ResendEmailRequestJson,
): Result<VerificationCodeResponseJson> =
unauthenticatedAccountsApi
.resendVerificationCodeEmail(body = body)
.toResult()
.map { VerificationCodeResponseJson.Success }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<VerificationCodeResponseJson.Invalid>(
code = NetworkErrorCode.BAD_REQUEST,
json = json,
)
?: throw throwable
}
override suspend fun resendNewDeviceOtp(body: ResendNewDeviceOtpRequestJson): Result<Unit> =
override suspend fun resendNewDeviceOtp(
body: ResendNewDeviceOtpRequestJson,
): Result<VerificationOtpResponseJson> =
unauthenticatedAccountsApi
.resendNewDeviceOtp(body = body)
.toResult()
.map { VerificationOtpResponseJson.Success }
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<VerificationOtpResponseJson.Invalid>(
code = NetworkErrorCode.BAD_REQUEST,
json = json,
)
?: throw throwable
}
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> =
if (body.currentPasswordHash == null) {

View File

@ -15,6 +15,8 @@ import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.VerificationCodeResponseJson
import com.bitwarden.network.model.VerificationOtpResponseJson
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
@ -136,7 +138,30 @@ class AccountsServiceTest : BaseServiceTest() {
ssoToken = null,
),
)
assertTrue(result.isSuccess)
assertEquals(VerificationCodeResponseJson.Success.asSuccess(), result)
}
@Test
fun `resendVerificationCodeEmail with 400 response is Error`() = runTest {
val response = MockResponse().setResponseCode(400).setBody(INVALID_JSON)
server.enqueue(response)
val result = service.resendVerificationCodeEmail(
body = ResendEmailRequestJson(
deviceIdentifier = "3",
email = "example@email.com",
passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93",
ssoToken = null,
),
)
assertEquals(
VerificationCodeResponseJson
.Invalid(
message = "User verification failed.",
validationErrors = null,
)
.asSuccess(),
result,
)
}
@Test
@ -264,4 +289,32 @@ class AccountsServiceTest : BaseServiceTest() {
)
assertTrue(result.isSuccess)
}
@Test
fun `resendNewDeviceOtp with 400 response is Error`() = runTest {
val response = MockResponse().setResponseCode(400).setBody(INVALID_JSON)
server.enqueue(response)
val result = service.resendNewDeviceOtp(
body = ResendNewDeviceOtpRequestJson(
email = "example@email.com",
passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93",
),
)
assertEquals(
VerificationOtpResponseJson
.Invalid(
message = "User verification failed.",
validationErrors = null,
)
.asSuccess(),
result,
)
}
}
private const val INVALID_JSON = """
{
"message": "User verification failed.",
"validationErrors": null
}
"""