Merge branch 'fix/cp-2714-account_creation_failure' into 'master'
Network timeout can cause account creation failure See merge request proton/mobile/android/proton-libs!416
This commit is contained in:
commit
5c785f61e9
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -1,3 +1,18 @@
|
|||
## Auth [1.18.4], Network [1.15.8], Util Kotlin [1.15.3]
|
||||
|
||||
22 Nov, 2021
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Contact 1.19.2
|
||||
- Payment 1.17.4
|
||||
|
||||
### Changes
|
||||
|
||||
- Minimum (and the default) network timeout in `ApiClient` is 30 seconds
|
||||
- Recover from error when creating new accounts ("Username already taken or not allowed")
|
||||
- Recover from errors while setting up user keys after creating an account ("Primary key exists")
|
||||
|
||||
## EventManager Version [1.19.2]
|
||||
|
||||
Nov 19, 2021
|
||||
|
|
|
@ -23,7 +23,7 @@ plugins {
|
|||
kotlin("android")
|
||||
}
|
||||
|
||||
libVersion = Version(1, 18, 3)
|
||||
libVersion = Version(1, 18, 4)
|
||||
|
||||
android()
|
||||
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.domain.usecase
|
||||
|
||||
import me.proton.core.auth.domain.usecase.signup.PerformCreateExternalEmailUser
|
||||
import me.proton.core.auth.domain.usecase.signup.PerformCreateUser
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
|
||||
/** Error from [SetupPrimaryKeys]. */
|
||||
fun Throwable.primaryKeyExists(): Boolean {
|
||||
val httpError = (this as? ApiException)?.error as? ApiResult.Error.Http
|
||||
return httpError?.proton?.code == ResponseCodes.NOT_ALLOWED
|
||||
}
|
||||
|
||||
/** Error from [PerformCreateUser] or [PerformCreateExternalEmailUser]. */
|
||||
fun Throwable.userAlreadyExists(): Boolean {
|
||||
val httpError = (this as? ApiException)?.error as? ApiResult.Error.Http
|
||||
return httpError?.proton?.code == ResponseCodes.USER_CREATE_NAME_INVALID
|
||||
}
|
|
@ -51,53 +51,51 @@ class SetupPrimaryKeys @Inject constructor(
|
|||
suspend operator fun invoke(
|
||||
userId: UserId,
|
||||
password: EncryptedString,
|
||||
accountType: AccountType
|
||||
) = with(userManager.getUser(userId)) {
|
||||
if (keys.primary() == null) {
|
||||
val username = when (accountType) {
|
||||
AccountType.External -> {
|
||||
checkNotNull(emailSplit?.username) { "Email username is needed to setup primary keys." }
|
||||
accountType: AccountType,
|
||||
) {
|
||||
val user = userManager.getUser(userId, refresh = true)
|
||||
if (user.keys.primary() != null) return
|
||||
|
||||
val username = if (accountType == AccountType.External) {
|
||||
checkNotNull(user.emailSplit?.username) { "Email username is needed to setup primary keys." }
|
||||
} else {
|
||||
checkNotNull(user.name) { "Username is needed." }
|
||||
}
|
||||
|
||||
val domain = if (accountType == AccountType.External) {
|
||||
checkNotNull(user.emailSplit?.domain) { "Email domain is needed to setup primary keys." }
|
||||
} else {
|
||||
domainRepository.getAvailableDomains().first()
|
||||
}
|
||||
|
||||
val email = if (accountType == AccountType.External) {
|
||||
checkNotNull(user.email) { "Email is needed." }
|
||||
} else {
|
||||
"$username@$domain"
|
||||
}
|
||||
val modulus = authRepository.randomModulus()
|
||||
|
||||
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
|
||||
val auth = srpCrypto.calculatePasswordVerifier(
|
||||
username = email,
|
||||
password = decryptedPassword.array,
|
||||
modulusId = modulus.modulusId,
|
||||
modulus = modulus.modulus
|
||||
)
|
||||
|
||||
if (accountType == AccountType.Internal) {
|
||||
if (userAddressRepository.getAddresses(userId, refresh = true).firstInternalOrNull() == null) {
|
||||
userAddressRepository.createAddress(userId, username, domain)
|
||||
}
|
||||
else -> checkNotNull(name) { "Username is needed." }
|
||||
}
|
||||
|
||||
val domain = when (accountType) {
|
||||
AccountType.External -> {
|
||||
checkNotNull(emailSplit?.domain) { "Email domain is needed to setup primary keys." }
|
||||
}
|
||||
else -> domainRepository.getAvailableDomains().first()
|
||||
}
|
||||
|
||||
val email = when (accountType) {
|
||||
AccountType.External -> checkNotNull(email) { "Email is needed." }
|
||||
else -> "$username@$domain"
|
||||
}
|
||||
val modulus = authRepository.randomModulus()
|
||||
|
||||
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
|
||||
val auth = srpCrypto.calculatePasswordVerifier(
|
||||
username = email,
|
||||
password = decryptedPassword.array,
|
||||
modulusId = modulus.modulusId,
|
||||
modulus = modulus.modulus
|
||||
)
|
||||
if (accountType == AccountType.Internal) {
|
||||
if (userAddressRepository.getAddresses(userId).firstInternalOrNull() == null) {
|
||||
userAddressRepository.createAddress(
|
||||
sessionUserId = userId,
|
||||
displayName = username,
|
||||
domain = domain
|
||||
)
|
||||
}
|
||||
}
|
||||
userManager.setupPrimaryKeys(
|
||||
sessionUserId = userId,
|
||||
username = username,
|
||||
domain = domain,
|
||||
auth = auth,
|
||||
password = decryptedPassword.array
|
||||
)
|
||||
}
|
||||
userManager.setupPrimaryKeys(
|
||||
sessionUserId = userId,
|
||||
username = username,
|
||||
domain = domain,
|
||||
auth = auth,
|
||||
password = decryptedPassword.array
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,8 +24,8 @@ import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
|||
import me.proton.core.crypto.common.keystore.decrypt
|
||||
import me.proton.core.crypto.common.keystore.use
|
||||
import me.proton.core.crypto.common.srp.SrpCrypto
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.user.domain.entity.CreateUserType
|
||||
import me.proton.core.user.domain.entity.User
|
||||
import me.proton.core.user.domain.repository.UserRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -40,8 +40,9 @@ class PerformCreateExternalEmailUser @Inject constructor(
|
|||
email: String,
|
||||
password: EncryptedString,
|
||||
referrer: String?
|
||||
): User {
|
||||
): UserId {
|
||||
require(email.isNotBlank()) { "Email must not be empty." }
|
||||
|
||||
val modulus = authRepository.randomModulus()
|
||||
|
||||
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
|
||||
|
@ -57,7 +58,7 @@ class PerformCreateExternalEmailUser @Inject constructor(
|
|||
referrer = referrer,
|
||||
type = CreateUserType.Normal,
|
||||
auth = auth
|
||||
)
|
||||
).userId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,8 +24,8 @@ import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
|||
import me.proton.core.crypto.common.keystore.decrypt
|
||||
import me.proton.core.crypto.common.keystore.use
|
||||
import me.proton.core.crypto.common.srp.SrpCrypto
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.user.domain.entity.CreateUserType
|
||||
import me.proton.core.user.domain.entity.User
|
||||
import me.proton.core.user.domain.repository.UserRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -42,13 +42,14 @@ class PerformCreateUser @Inject constructor(
|
|||
recoveryEmail: String?,
|
||||
recoveryPhone: String?,
|
||||
referrer: String?,
|
||||
type: CreateUserType,
|
||||
): User {
|
||||
type: CreateUserType
|
||||
): UserId {
|
||||
require(
|
||||
(recoveryEmail == null && recoveryPhone == null) ||
|
||||
(recoveryEmail == null && recoveryPhone != null) ||
|
||||
(recoveryEmail != null && recoveryPhone == null)
|
||||
recoveryEmail == null && recoveryPhone == null ||
|
||||
recoveryEmail == null && recoveryPhone != null ||
|
||||
recoveryEmail != null && recoveryPhone == null
|
||||
) { "Recovery Email and Phone could not be set together" }
|
||||
|
||||
val modulus = authRepository.randomModulus()
|
||||
|
||||
password.decrypt(keyStoreCrypto).toByteArray().use { decryptedPassword ->
|
||||
|
@ -66,7 +67,7 @@ class PerformCreateUser @Inject constructor(
|
|||
referrer = referrer,
|
||||
type = type,
|
||||
auth = auth
|
||||
)
|
||||
).userId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,282 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.domain.usecase
|
||||
|
||||
import io.mockk.Called
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.auth.domain.entity.Modulus
|
||||
import me.proton.core.auth.domain.repository.AuthRepository
|
||||
import me.proton.core.crypto.common.keystore.EncryptedString
|
||||
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
||||
import me.proton.core.crypto.common.srp.Auth
|
||||
import me.proton.core.crypto.common.srp.SrpCrypto
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.key.domain.entity.key.PrivateKey
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes.APP_VERSION_BAD
|
||||
import me.proton.core.network.domain.ResponseCodes.NOT_ALLOWED
|
||||
import me.proton.core.user.domain.UserManager
|
||||
import me.proton.core.user.domain.entity.User
|
||||
import me.proton.core.user.domain.entity.UserAddress
|
||||
import me.proton.core.user.domain.entity.UserKey
|
||||
import me.proton.core.user.domain.repository.DomainRepository
|
||||
import me.proton.core.user.domain.repository.UserAddressRepository
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class SetupPrimaryKeysTest {
|
||||
private val decryptedPassword = "decrypted-password"
|
||||
private val encryptedPassword: EncryptedString = "encrypted-password"
|
||||
private val testUsername = "test-username"
|
||||
private val testDomain = "example.com"
|
||||
private val testEmail = "$testUsername@$testDomain"
|
||||
private val testModulus = Modulus(modulusId = "test-id", modulus = "test-modulus")
|
||||
private val testUserId = UserId("test-user-id")
|
||||
private val testAuth = Auth(
|
||||
version = 0,
|
||||
modulusId = testModulus.modulusId,
|
||||
salt = "test-salt",
|
||||
verifier = "test-verifier"
|
||||
)
|
||||
|
||||
private lateinit var userManager: UserManager
|
||||
private lateinit var userAddressRepository: UserAddressRepository
|
||||
private lateinit var authRepository: AuthRepository
|
||||
private lateinit var domainRepository: DomainRepository
|
||||
private lateinit var srpCrypto: SrpCrypto
|
||||
private lateinit var keyStoreCrypto: KeyStoreCrypto
|
||||
private lateinit var tested: SetupPrimaryKeys
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
userManager = mockk()
|
||||
userAddressRepository = mockk()
|
||||
authRepository = mockk()
|
||||
domainRepository = mockk()
|
||||
srpCrypto = mockk()
|
||||
keyStoreCrypto = mockk()
|
||||
tested = SetupPrimaryKeys(
|
||||
userManager,
|
||||
userAddressRepository,
|
||||
authRepository,
|
||||
domainRepository,
|
||||
srpCrypto,
|
||||
keyStoreCrypto
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primary key already exists`() = runBlockingTest {
|
||||
userManager.mockGetUser(mockUser(withPrimaryPrivateKey = true))
|
||||
|
||||
tested.invoke(testUserId, encryptedPassword, mockk())
|
||||
|
||||
coVerify { userManager.setupPrimaryKeys(any(), any(), any(), any(), any()) wasNot Called }
|
||||
coVerify { userAddressRepository wasNot Called }
|
||||
coVerify { authRepository wasNot Called }
|
||||
coVerify { domainRepository wasNot Called }
|
||||
coVerify { srpCrypto wasNot Called }
|
||||
coVerify { keyStoreCrypto wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup primary keys for internal account`() = runBlockingTest {
|
||||
authRepository.mockRandomModulus()
|
||||
domainRepository.mockGetAvailableDomains()
|
||||
keyStoreCrypto.mockDecrypt()
|
||||
srpCrypto.mockCalculatePasswordVerifier(testEmail)
|
||||
userAddressRepository.mockCreateAddress(displayName = testUsername)
|
||||
userAddressRepository.mockGetAddress()
|
||||
userManager.mockGetUser(mockUser(username = testUsername))
|
||||
userManager.mockSetupPrimaryKeys(testUsername)
|
||||
|
||||
tested.invoke(testUserId, encryptedPassword, AccountType.Internal)
|
||||
|
||||
coVerify(exactly = 1) { userAddressRepository.createAddress(testUserId, testUsername, testDomain) }
|
||||
coVerify(exactly = 1) {
|
||||
userManager.setupPrimaryKeys(
|
||||
testUserId,
|
||||
testUsername,
|
||||
testDomain,
|
||||
testAuth,
|
||||
withArg { it contentEquals decryptedPassword.toByteArray() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup primary keys for external account`() = runBlockingTest {
|
||||
authRepository.mockRandomModulus()
|
||||
domainRepository.mockGetAvailableDomains()
|
||||
keyStoreCrypto.mockDecrypt()
|
||||
srpCrypto.mockCalculatePasswordVerifier(testEmail)
|
||||
userAddressRepository.mockCreateAddress(displayName = testUsername)
|
||||
userAddressRepository.mockGetAddress()
|
||||
userManager.mockGetUser(mockUser(userEmail = testEmail))
|
||||
userManager.mockSetupPrimaryKeys(testUsername)
|
||||
|
||||
tested.invoke(testUserId, encryptedPassword, AccountType.External)
|
||||
|
||||
coVerify { userAddressRepository wasNot Called }
|
||||
coVerify(exactly = 1) {
|
||||
userManager.setupPrimaryKeys(
|
||||
testUserId,
|
||||
testUsername,
|
||||
testDomain,
|
||||
testAuth,
|
||||
withArg { it contentEquals decryptedPassword.toByteArray() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails to recover from error when creating address`() = runBlockingTest {
|
||||
authRepository.mockRandomModulus()
|
||||
domainRepository.mockGetAvailableDomains()
|
||||
keyStoreCrypto.mockDecrypt()
|
||||
srpCrypto.mockCalculatePasswordVerifier(testEmail)
|
||||
val data = ApiResult.Error.ProtonData(NOT_ALLOWED, "User has already set up an address")
|
||||
val apiException = ApiException(ApiResult.Error.Http(400, "Bad request", data))
|
||||
userAddressRepository.mockCreateAddress(displayName = testUsername, withException = apiException)
|
||||
userAddressRepository.mockGetAddress()
|
||||
userManager.mockGetUser(mockUser(username = testUsername))
|
||||
userManager.mockSetupPrimaryKeys(testUsername)
|
||||
|
||||
val result = assertFailsWith(ApiException::class) {
|
||||
tested.invoke(testUserId, encryptedPassword, AccountType.Internal)
|
||||
}
|
||||
assertEquals("User has already set up an address", result.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails to recover from error when setting up keys`() = runBlockingTest {
|
||||
authRepository.mockRandomModulus()
|
||||
domainRepository.mockGetAvailableDomains()
|
||||
keyStoreCrypto.mockDecrypt()
|
||||
srpCrypto.mockCalculatePasswordVerifier(testEmail)
|
||||
userAddressRepository.mockCreateAddress(displayName = testUsername)
|
||||
userAddressRepository.mockGetAddress()
|
||||
userManager.mockGetUser(mockUser(username = testUsername))
|
||||
val data = ApiResult.Error.ProtonData(NOT_ALLOWED, "Primary key exists")
|
||||
val apiException = ApiException(ApiResult.Error.Http(400, "Bad request", data))
|
||||
userManager.mockSetupPrimaryKeys(testUsername, withException = apiException)
|
||||
|
||||
val result = assertFailsWith(ApiException::class) {
|
||||
tested.invoke(testUserId, encryptedPassword, AccountType.Internal)
|
||||
}
|
||||
assertEquals("Primary key exists", result.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rethrows other exceptions`() = runBlockingTest {
|
||||
val data = ApiResult.Error.ProtonData(APP_VERSION_BAD, "Unsupported API version")
|
||||
val apiException = ApiException(ApiResult.Error.Http(400, "Bad request", data))
|
||||
coEvery { userManager.getUser(testUserId, any()) } throws apiException
|
||||
|
||||
val result = assertFailsWith<ApiException> {
|
||||
tested.invoke(testUserId, encryptedPassword, mockk())
|
||||
}
|
||||
assertEquals("Unsupported API version", result.message)
|
||||
}
|
||||
|
||||
//region Mock helpers
|
||||
|
||||
private fun mockUser(
|
||||
userEmail: String? = null,
|
||||
username: String? = null,
|
||||
withPrimaryPrivateKey: Boolean = false
|
||||
): User {
|
||||
return mockk {
|
||||
every { email } returns userEmail
|
||||
every { name } returns username
|
||||
every { keys } returns if (withPrimaryPrivateKey) {
|
||||
val userPrivateKey = mockk<PrivateKey> { every { isPrimary } returns true }
|
||||
val userKey = mockk<UserKey> { every { privateKey } returns userPrivateKey }
|
||||
listOf(userKey)
|
||||
} else emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun AuthRepository.mockRandomModulus() {
|
||||
coEvery { randomModulus() } returns testModulus
|
||||
}
|
||||
|
||||
private fun DomainRepository.mockGetAvailableDomains() {
|
||||
coEvery { getAvailableDomains() } returns listOf(testDomain)
|
||||
}
|
||||
|
||||
private fun KeyStoreCrypto.mockDecrypt() {
|
||||
every { decrypt(encryptedPassword) } returns decryptedPassword
|
||||
}
|
||||
|
||||
private fun SrpCrypto.mockCalculatePasswordVerifier(email: String) {
|
||||
every {
|
||||
calculatePasswordVerifier(
|
||||
email,
|
||||
any(),
|
||||
testModulus.modulusId,
|
||||
testModulus.modulus
|
||||
)
|
||||
} returns testAuth
|
||||
}
|
||||
|
||||
private fun UserAddressRepository.mockCreateAddress(displayName: String, withException: Throwable? = null) {
|
||||
coEvery { createAddress(testUserId, displayName, testDomain) } answers {
|
||||
if (withException != null) {
|
||||
throw withException
|
||||
} else {
|
||||
mockk()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserAddressRepository.mockGetAddress(vararg responses: List<UserAddress>) {
|
||||
coEvery { getAddresses(testUserId, any()) }.apply {
|
||||
if (responses.isNotEmpty()) {
|
||||
returnsMany(responses.toList())
|
||||
} else {
|
||||
returns(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserManager.mockGetUser(vararg users: User) {
|
||||
coEvery { getUser(testUserId, any()) } returnsMany users.toList()
|
||||
}
|
||||
|
||||
private fun UserManager.mockSetupPrimaryKeys(username: String, withException: Throwable? = null) {
|
||||
coEvery { setupPrimaryKeys(testUserId, username, testDomain, testAuth, any()) }.apply {
|
||||
if (withException != null) {
|
||||
throws(withException)
|
||||
} else {
|
||||
returns(mockk { every { userId } returns testUserId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
|
@ -31,6 +31,7 @@ import me.proton.core.crypto.common.keystore.EncryptedString
|
|||
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
||||
import me.proton.core.crypto.common.srp.Auth
|
||||
import me.proton.core.crypto.common.srp.SrpCrypto
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.user.domain.entity.CreateUserType
|
||||
import me.proton.core.user.domain.repository.UserRepository
|
||||
import org.junit.Assert.assertEquals
|
||||
|
@ -38,6 +39,7 @@ import org.junit.Assert.assertNotNull
|
|||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertSame
|
||||
|
||||
class PerformCreateExternalEmailUserTest {
|
||||
// region mocks
|
||||
|
@ -50,6 +52,7 @@ class PerformCreateExternalEmailUserTest {
|
|||
|
||||
// region test data
|
||||
private val testPassword = "test-password"
|
||||
private val testEncryptedPassword = "encrypted-$testPassword"
|
||||
private val testEmail = "test-email"
|
||||
private val testModulus = Modulus(modulusId = "test-id", modulus = "test-modulus")
|
||||
private val testAuth = Auth(
|
||||
|
@ -66,14 +69,23 @@ class PerformCreateExternalEmailUserTest {
|
|||
@Before
|
||||
fun beforeEveryTest() {
|
||||
// GIVEN
|
||||
useCase = PerformCreateExternalEmailUser(authRepository, userRepository, srpCrypto, keyStoreCrypto)
|
||||
useCase = PerformCreateExternalEmailUser(
|
||||
authRepository,
|
||||
userRepository,
|
||||
srpCrypto,
|
||||
keyStoreCrypto
|
||||
)
|
||||
every {
|
||||
srpCrypto.calculatePasswordVerifier(testEmail, any(), any(), any())
|
||||
} returns testAuth
|
||||
every { keyStoreCrypto.decrypt(any<String>()) } returns testPassword
|
||||
every { keyStoreCrypto.encrypt(any<String>()) } returns "encrypted-$testPassword"
|
||||
every { keyStoreCrypto.encrypt(any<String>()) } returns testEncryptedPassword
|
||||
|
||||
coEvery { authRepository.randomModulus() } returns testModulus
|
||||
|
||||
coEvery {
|
||||
userRepository.createExternalEmailUser(any(), any(), any(), any(), any())
|
||||
} returns mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -125,4 +137,21 @@ class PerformCreateExternalEmailUserTest {
|
|||
throwable.message
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user already exists`() = runBlockingTest {
|
||||
val apiException = mockk<ApiException>()
|
||||
coEvery {
|
||||
userRepository.createExternalEmailUser(any(), any(), any(), any(), any())
|
||||
} throws apiException
|
||||
|
||||
val result = assertFailsWith(ApiException::class) {
|
||||
useCase.invoke(
|
||||
testEmail,
|
||||
keyStoreCrypto.encrypt(testPassword),
|
||||
referrer = null
|
||||
)
|
||||
}
|
||||
assertSame(apiException, result)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,14 +31,15 @@ import me.proton.core.crypto.common.keystore.EncryptedString
|
|||
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
||||
import me.proton.core.crypto.common.srp.Auth
|
||||
import me.proton.core.crypto.common.srp.SrpCrypto
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.user.domain.entity.CreateUserType
|
||||
import me.proton.core.user.domain.repository.UserRepository
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.lang.IllegalArgumentException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertSame
|
||||
|
||||
class PerformCreateUserTest {
|
||||
// region mocks
|
||||
|
@ -52,6 +53,7 @@ class PerformCreateUserTest {
|
|||
// region test data
|
||||
private val testUsername = "test-username"
|
||||
private val testPassword = "test-password"
|
||||
private val testEncryptedPassword = "encrypted-$testPassword"
|
||||
private val testEmail = "test-email"
|
||||
private val testPhone = "test-phone"
|
||||
private val testModulus = Modulus(modulusId = "test-id", modulus = "test-modulus")
|
||||
|
@ -74,9 +76,12 @@ class PerformCreateUserTest {
|
|||
srpCrypto.calculatePasswordVerifier(testUsername, any(), any(), any())
|
||||
} returns testAuth
|
||||
every { keyStoreCrypto.decrypt(any<String>()) } returns testPassword
|
||||
every { keyStoreCrypto.encrypt(any<String>()) } returns "encrypted-$testPassword"
|
||||
every { keyStoreCrypto.encrypt(any<String>()) } returns testEncryptedPassword
|
||||
|
||||
coEvery { authRepository.randomModulus() } returns testModulus
|
||||
coEvery {
|
||||
userRepository.createUser(any(), any(), any(), any(), any(), any(), any())
|
||||
} returns mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -219,4 +224,25 @@ class PerformCreateUserTest {
|
|||
throwable.message
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user already exists and cannot log in`() = runBlockingTest {
|
||||
val apiException = mockk<ApiException>()
|
||||
|
||||
coEvery {
|
||||
userRepository.createUser(any(), any(), any(), any(), any(), any(), any())
|
||||
} throws apiException
|
||||
|
||||
val result = assertFailsWith(ApiException::class) {
|
||||
useCase.invoke(
|
||||
testEmail,
|
||||
keyStoreCrypto.encrypt(testPassword),
|
||||
recoveryEmail = null,
|
||||
recoveryPhone = null,
|
||||
referrer = null,
|
||||
type = CreateUserType.Normal
|
||||
)
|
||||
}
|
||||
assertSame(apiException, result)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2020 Proton Technologies AG
|
||||
* Copyright (c) 2021 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
|
@ -16,14 +16,9 @@
|
|||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.network.data
|
||||
package me.proton.core.auth.presentation
|
||||
|
||||
/**
|
||||
* Contains general constants response codes.
|
||||
*
|
||||
* @author Dino Kadrikj.
|
||||
*/
|
||||
object ResponseCodes {
|
||||
const val OK = 1000
|
||||
const val NOT_EXISTS = 2501
|
||||
object LogTag {
|
||||
/** Tag for marking when a flow has failed with an exception, but it will be retried. */
|
||||
const val FLOW_ERROR_RETRY = "core.auth.presentation.flow.retry"
|
||||
}
|
|
@ -31,12 +31,16 @@ import me.proton.core.auth.domain.AccountWorkflowHandler
|
|||
import me.proton.core.auth.domain.usecase.SetupInternalAddress
|
||||
import me.proton.core.auth.domain.usecase.SetupPrimaryKeys
|
||||
import me.proton.core.auth.domain.usecase.UnlockUserPrimaryKey
|
||||
import me.proton.core.auth.domain.usecase.primaryKeyExists
|
||||
import me.proton.core.auth.presentation.LogTag
|
||||
import me.proton.core.crypto.common.keystore.EncryptedString
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.presentation.viewmodel.ProtonViewModel
|
||||
import me.proton.core.user.domain.UserManager
|
||||
import me.proton.core.user.domain.extension.firstInternalOrNull
|
||||
import me.proton.core.usersettings.domain.usecase.SetupUsername
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import me.proton.core.util.kotlin.retryOnceWhen
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -90,6 +94,8 @@ class CreateAddressViewModel @Inject constructor(
|
|||
}.let {
|
||||
emit(it)
|
||||
}
|
||||
}.retryOnceWhen(Throwable::primaryKeyExists) {
|
||||
CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying to upgrade an account")
|
||||
}.catch { error ->
|
||||
emit(State.Error.Message(error.message))
|
||||
}.onEach {
|
||||
|
|
|
@ -33,14 +33,18 @@ import kotlinx.coroutines.launch
|
|||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.auth.domain.AccountWorkflowHandler
|
||||
import me.proton.core.auth.domain.entity.BillingDetails
|
||||
import me.proton.core.auth.domain.usecase.PostLoginAccountSetup
|
||||
import me.proton.core.auth.domain.usecase.CreateLoginSession
|
||||
import me.proton.core.auth.domain.usecase.PostLoginAccountSetup
|
||||
import me.proton.core.auth.domain.usecase.primaryKeyExists
|
||||
import me.proton.core.auth.presentation.LogTag
|
||||
import me.proton.core.crypto.common.keystore.EncryptedString
|
||||
import me.proton.core.crypto.common.keystore.KeyStoreCrypto
|
||||
import me.proton.core.crypto.common.keystore.encrypt
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.humanverification.domain.HumanVerificationManager
|
||||
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import me.proton.core.util.kotlin.retryOnceWhen
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -101,6 +105,8 @@ internal class LoginViewModel @Inject constructor(
|
|||
|
||||
val result = postLoginAccountSetup(sessionInfo, encryptedPassword, requiredAccountType, billingDetails)
|
||||
emit(State.AccountSetupResult(result))
|
||||
}.retryOnceWhen(Throwable::primaryKeyExists) {
|
||||
CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying login flow")
|
||||
}.catch { error ->
|
||||
emit(State.ErrorMessage(error.message))
|
||||
}.onEach { state ->
|
||||
|
|
|
@ -42,6 +42,8 @@ import me.proton.core.auth.domain.usecase.SetupAccountCheck.Result.UserCheckErro
|
|||
import me.proton.core.auth.domain.usecase.SetupInternalAddress
|
||||
import me.proton.core.auth.domain.usecase.SetupPrimaryKeys
|
||||
import me.proton.core.auth.domain.usecase.UnlockUserPrimaryKey
|
||||
import me.proton.core.auth.domain.usecase.primaryKeyExists
|
||||
import me.proton.core.auth.presentation.LogTag
|
||||
import me.proton.core.crypto.common.keystore.EncryptedString
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.network.domain.ApiException
|
||||
|
@ -49,6 +51,8 @@ import me.proton.core.network.domain.ApiResult
|
|||
import me.proton.core.network.domain.session.SessionProvider
|
||||
import me.proton.core.presentation.viewmodel.ProtonViewModel
|
||||
import me.proton.core.user.domain.UserManager
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import me.proton.core.util.kotlin.retryOnceWhen
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
@ -122,6 +126,8 @@ class SecondFactorViewModel @Inject constructor(
|
|||
}.let {
|
||||
emit(it)
|
||||
}
|
||||
}.retryOnceWhen(Throwable::primaryKeyExists) {
|
||||
CoreLogger.e(LogTag.FLOW_ERROR_RETRY, it, "Retrying second factor flow")
|
||||
}.catch { error ->
|
||||
if (error.isUnrecoverableError()) {
|
||||
emit(State.Error.Unrecoverable)
|
||||
|
|
|
@ -33,8 +33,10 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.auth.domain.usecase.PerformLogin
|
||||
import me.proton.core.auth.domain.usecase.signup.PerformCreateExternalEmailUser
|
||||
import me.proton.core.auth.domain.usecase.signup.PerformCreateUser
|
||||
import me.proton.core.auth.domain.usecase.userAlreadyExists
|
||||
import me.proton.core.auth.presentation.entity.signup.RecoveryMethod
|
||||
import me.proton.core.auth.presentation.entity.signup.RecoveryMethodType
|
||||
import me.proton.core.auth.presentation.entity.signup.SubscriptionDetails
|
||||
|
@ -45,8 +47,6 @@ import me.proton.core.crypto.common.keystore.encrypt
|
|||
import me.proton.core.humanverification.domain.HumanVerificationManager
|
||||
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
|
||||
import me.proton.core.humanverification.presentation.onHumanVerificationFailed
|
||||
import me.proton.core.humanverification.presentation.onHumanVerificationNeeded
|
||||
import me.proton.core.humanverification.presentation.onHumanVerificationSucceeded
|
||||
import me.proton.core.network.domain.client.ClientIdProvider
|
||||
import me.proton.core.payment.domain.entity.SubscriptionCycle
|
||||
import me.proton.core.payment.presentation.PaymentsOrchestrator
|
||||
|
@ -55,6 +55,7 @@ import me.proton.core.plan.presentation.PlansOrchestrator
|
|||
import me.proton.core.presentation.savedstate.flowState
|
||||
import me.proton.core.presentation.savedstate.state
|
||||
import me.proton.core.user.domain.entity.createUserType
|
||||
import me.proton.core.util.kotlin.catchWhen
|
||||
import me.proton.core.util.kotlin.exhaustive
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -67,6 +68,7 @@ internal class SignupViewModel @Inject constructor(
|
|||
private val paymentsOrchestrator: PaymentsOrchestrator,
|
||||
private val clientIdProvider: ClientIdProvider,
|
||||
private val humanVerificationManager: HumanVerificationManager,
|
||||
private val performLogin: PerformLogin,
|
||||
humanVerificationOrchestrator: HumanVerificationOrchestrator,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : AuthViewModel(humanVerificationManager, humanVerificationOrchestrator) {
|
||||
|
@ -162,12 +164,21 @@ internal class SignupViewModel @Inject constructor(
|
|||
* previously set [AccountType].
|
||||
* @see currentAccountType public property
|
||||
*/
|
||||
fun startCreateUserWorkflow() = viewModelScope.launch {
|
||||
fun startCreateUserWorkflow() {
|
||||
_userCreationState.tryEmit(State.Idle)
|
||||
|
||||
val password by lazy { requireNotNull(_password) { "Password is not set (initialized)." } }
|
||||
|
||||
when (currentAccountType) {
|
||||
AccountType.Username,
|
||||
AccountType.Internal -> createUser()
|
||||
AccountType.External -> createExternalUser()
|
||||
AccountType.Internal -> {
|
||||
val username = requireNotNull(username) { "Username is not set." }
|
||||
createUser(username, password)
|
||||
}
|
||||
AccountType.External -> {
|
||||
val email = requireNotNull(externalEmail) { "External email is not set." }
|
||||
createExternalUser(email, password)
|
||||
}
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
|
@ -207,49 +218,55 @@ internal class SignupViewModel @Inject constructor(
|
|||
// endregion
|
||||
|
||||
// region private functions
|
||||
private suspend fun createUser() = flow {
|
||||
val username = requireNotNull(username) { "Username is not set." }
|
||||
val encryptedPassword = requireNotNull(_password) { "Password is not set (initialized)." }
|
||||
emit(State.Processing)
|
||||
private fun createUser(username: String, encryptedPassword: EncryptedString) {
|
||||
flow {
|
||||
emit(State.Processing)
|
||||
|
||||
val verification = _recoveryMethod?.let {
|
||||
val email = if (it.type == RecoveryMethodType.EMAIL) {
|
||||
it.destination
|
||||
} else null
|
||||
val phone = if (it.type == RecoveryMethodType.SMS) {
|
||||
it.destination
|
||||
} else null
|
||||
Pair(email, phone)
|
||||
} ?: run {
|
||||
Pair(null, null)
|
||||
}
|
||||
val verification = _recoveryMethod?.let {
|
||||
val email = if (it.type == RecoveryMethodType.EMAIL) {
|
||||
it.destination
|
||||
} else null
|
||||
val phone = if (it.type == RecoveryMethodType.SMS) {
|
||||
it.destination
|
||||
} else null
|
||||
Pair(email, phone)
|
||||
} ?: run {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
val result = performCreateUser(
|
||||
username = username, password = encryptedPassword, recoveryEmail = verification.first,
|
||||
recoveryPhone = verification.second, referrer = null, type = currentAccountType.createUserType()
|
||||
)
|
||||
emit(State.Success(result.userId.id, username, encryptedPassword))
|
||||
}.catch { error ->
|
||||
emit(State.Error.Message(error.message))
|
||||
}.onEach {
|
||||
_userCreationState.tryEmit(it)
|
||||
}.launchIn(viewModelScope)
|
||||
val result = performCreateUser(
|
||||
username = username, password = encryptedPassword, recoveryEmail = verification.first,
|
||||
recoveryPhone = verification.second, referrer = null, type = currentAccountType.createUserType()
|
||||
)
|
||||
emit(State.Success(result.id, username, encryptedPassword))
|
||||
}.catchWhen(Throwable::userAlreadyExists) {
|
||||
val userId = performLogin.invoke(username, encryptedPassword).userId
|
||||
emit(State.Success(userId.id, username, encryptedPassword))
|
||||
}.catch { error ->
|
||||
emit(State.Error.Message(error.message))
|
||||
}.onEach {
|
||||
_userCreationState.tryEmit(it)
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun createExternalUser() = flow {
|
||||
val externalEmail = requireNotNull(externalEmail) { "External email is not set." }
|
||||
val encryptedPassword = requireNotNull(_password) { "Password is not set (initialized)." }
|
||||
emit(State.Processing)
|
||||
val user = performCreateExternalEmailUser(
|
||||
email = externalEmail,
|
||||
password = encryptedPassword,
|
||||
referrer = null
|
||||
)
|
||||
emit(State.Success(user.userId.id, externalEmail, encryptedPassword))
|
||||
}.catch { error ->
|
||||
emit(State.Error.Message(error.message))
|
||||
}.onEach {
|
||||
_userCreationState.tryEmit(it)
|
||||
}.launchIn(viewModelScope)
|
||||
private fun createExternalUser(externalEmail: String, encryptedPassword: EncryptedString) {
|
||||
flow {
|
||||
emit(State.Processing)
|
||||
val userId = performCreateExternalEmailUser(
|
||||
email = externalEmail,
|
||||
password = encryptedPassword,
|
||||
referrer = null
|
||||
)
|
||||
emit(State.Success(userId.id, externalEmail, encryptedPassword))
|
||||
}.catchWhen(Throwable::userAlreadyExists) {
|
||||
val userId = performLogin.invoke(externalEmail, encryptedPassword).userId
|
||||
emit(State.Success(userId.id, externalEmail, encryptedPassword))
|
||||
}.catch { error ->
|
||||
emit(State.Error.Message(error.message))
|
||||
}.onEach {
|
||||
_userCreationState.tryEmit(it)
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun onUserCreationStateRestored(state: State) {
|
||||
if (state == State.Processing) {
|
||||
|
|
|
@ -21,6 +21,7 @@ package me.proton.core.auth.presentation.viewmodel
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
|
@ -35,6 +36,7 @@ import me.proton.core.humanverification.domain.HumanVerificationManager
|
|||
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.test.android.ArchTest
|
||||
import me.proton.core.test.kotlin.CoroutinesTest
|
||||
import me.proton.core.test.kotlin.assertIs
|
||||
|
@ -214,6 +216,39 @@ class LoginViewModelTest : ArchTest, CoroutinesTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login is retried on Primary Key Exists error`() = coroutinesTest {
|
||||
val sessionInfo = mockSessionInfo()
|
||||
coEvery { createLoginSession.invoke(any(), any(), any()) } returns sessionInfo
|
||||
coEvery {
|
||||
postLoginAccountSetup.invoke(any(), any(), any(), any())
|
||||
} throws ApiException(
|
||||
ApiResult.Error.Http(
|
||||
400,
|
||||
"Bad request",
|
||||
ApiResult.Error.ProtonData(ResponseCodes.NOT_ALLOWED, "Primary key exists")
|
||||
)
|
||||
)
|
||||
|
||||
viewModel.state.test {
|
||||
// WHEN
|
||||
viewModel.startLoginWorkflow(testUserName, testPassword, mockk())
|
||||
|
||||
// THEN
|
||||
assertIs<LoginViewModel.State.Processing>(awaitItem())
|
||||
assertIs<LoginViewModel.State.Processing>(awaitItem()) // retried
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState is LoginViewModel.State.ErrorMessage)
|
||||
assertEquals("Primary key exists", errorState.message)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
coVerify(exactly = 2) { createLoginSession.invoke(testUserName, testPassword, any()) }
|
||||
coVerify(exactly = 2) { postLoginAccountSetup.invoke(any(), testPassword, any(), any()) }
|
||||
}
|
||||
|
||||
private fun mockSessionInfo() = mockk<SessionInfo> {
|
||||
every { userId } returns testUserId
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import io.mockk.coVerify
|
|||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.auth.domain.usecase.PerformLogin
|
||||
import me.proton.core.auth.domain.usecase.signup.PerformCreateExternalEmailUser
|
||||
import me.proton.core.auth.domain.usecase.signup.PerformCreateUser
|
||||
import me.proton.core.auth.presentation.entity.signup.RecoveryMethod
|
||||
|
@ -34,6 +35,7 @@ import me.proton.core.humanverification.domain.HumanVerificationManager
|
|||
import me.proton.core.humanverification.presentation.HumanVerificationOrchestrator
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.network.domain.client.ClientIdProvider
|
||||
import me.proton.core.payment.presentation.PaymentsOrchestrator
|
||||
import me.proton.core.plan.presentation.PlansOrchestrator
|
||||
|
@ -44,6 +46,7 @@ import me.proton.core.user.domain.entity.User
|
|||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SignupViewModelTest : ArchTest, CoroutinesTest {
|
||||
|
@ -57,6 +60,7 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
private val plansOrchestrator = mockk<PlansOrchestrator>(relaxed = true)
|
||||
private val paymentsOrchestrator = mockk<PaymentsOrchestrator>(relaxed = true)
|
||||
private val clientIdProvider = mockk<ClientIdProvider>(relaxed = true)
|
||||
private val performLogin = mockk<PerformLogin>()
|
||||
// endregion
|
||||
|
||||
|
||||
|
@ -84,6 +88,15 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
keys = emptyList()
|
||||
)
|
||||
|
||||
private val usernameTakenError: ApiException
|
||||
get() = ApiException(
|
||||
ApiResult.Error.Http(
|
||||
409,
|
||||
"Conflict",
|
||||
ApiResult.Error.ProtonData(ResponseCodes.USER_CREATE_NAME_INVALID, "Username taken")
|
||||
)
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
private lateinit var viewModel: SignupViewModel
|
||||
|
@ -98,6 +111,7 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
paymentsOrchestrator,
|
||||
clientIdProvider,
|
||||
humanVerificationManager,
|
||||
performLogin,
|
||||
humanVerificationOrchestrator,
|
||||
mockk(relaxed = true)
|
||||
)
|
||||
|
@ -113,7 +127,7 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
referrer = null,
|
||||
type = any()
|
||||
)
|
||||
} returns testUser.copy(name = testUsername)
|
||||
} returns testUser.userId
|
||||
|
||||
coEvery {
|
||||
performCreateExternalUser.invoke(
|
||||
|
@ -121,19 +135,15 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
password = any(),
|
||||
referrer = null
|
||||
)
|
||||
} returns testUser.copy(name = testUsername)
|
||||
} returns testUser.userId
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create Internal user no username no password set`() = coroutinesTest {
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
val errorItem = awaitItem()
|
||||
assertTrue(errorItem is SignupViewModel.State.Error.Message)
|
||||
assertEquals("Username is not set.", errorItem.message)
|
||||
assertEquals("Username is not set.", throwable.message)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
performCreateUser(
|
||||
|
@ -154,13 +164,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
viewModel.setPassword(testPassword)
|
||||
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
val errorItem = awaitItem()
|
||||
assertTrue(errorItem is SignupViewModel.State.Error.Message)
|
||||
assertEquals("Username is not set.", errorItem.message)
|
||||
assertEquals("Username is not set.", throwable.message)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
performCreateUser(
|
||||
|
@ -180,13 +186,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
// GIVEN
|
||||
viewModel.username = testUsername
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
val errorItem = awaitItem()
|
||||
assertTrue(errorItem is SignupViewModel.State.Error.Message)
|
||||
assertEquals("Password is not set (initialized).", errorItem.message)
|
||||
assertEquals("Password is not set (initialized).", throwable.message)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
performCreateUser(
|
||||
|
@ -342,13 +344,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
// GIVEN
|
||||
viewModel.currentAccountType = AccountType.External
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
val errorItem = awaitItem()
|
||||
assertTrue(errorItem is SignupViewModel.State.Error.Message)
|
||||
assertEquals("External email is not set.", errorItem.message)
|
||||
assertEquals("External email is not set.", throwable.message)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
performCreateExternalUser(
|
||||
|
@ -366,13 +364,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
viewModel.currentAccountType = AccountType.External
|
||||
viewModel.setPassword(testPassword)
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
val errorItem = awaitItem()
|
||||
assertTrue(errorItem is SignupViewModel.State.Error.Message)
|
||||
assertEquals("External email is not set.", errorItem.message)
|
||||
assertEquals("External email is not set.", throwable.message)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
performCreateExternalUser(
|
||||
|
@ -390,13 +384,9 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
viewModel.currentAccountType = AccountType.External
|
||||
viewModel.externalEmail = testEmail
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
val throwable = assertFails { viewModel.startCreateUserWorkflow() }
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
val errorItem = awaitItem()
|
||||
assertTrue(errorItem is SignupViewModel.State.Error.Message)
|
||||
assertEquals("Password is not set (initialized).", errorItem.message)
|
||||
assertEquals("Password is not set (initialized).", throwable.message)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
performCreateExternalUser(
|
||||
|
@ -476,4 +466,100 @@ class SignupViewModelTest : ArchTest, CoroutinesTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tries login if internal username taken`() = coroutinesTest {
|
||||
coEvery {
|
||||
performCreateUser.invoke(
|
||||
username = testUsername,
|
||||
password = any(),
|
||||
recoveryEmail = any(),
|
||||
recoveryPhone = any(),
|
||||
referrer = null,
|
||||
type = any()
|
||||
)
|
||||
} throws usernameTakenError
|
||||
|
||||
coEvery { performLogin.invoke(testUsername, any()) } returns mockk {
|
||||
every { userId } returns testUser.userId
|
||||
}
|
||||
|
||||
// GIVEN
|
||||
viewModel.username = testUsername
|
||||
viewModel.setPassword(testPassword)
|
||||
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Processing)
|
||||
val successItem = awaitItem()
|
||||
assertTrue(successItem is SignupViewModel.State.Success)
|
||||
assertEquals(testUser.userId.id, successItem.userId)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
performCreateUser(
|
||||
username = testUsername,
|
||||
password = "encrypted-$testPassword",
|
||||
recoveryEmail = null,
|
||||
recoveryPhone = null,
|
||||
referrer = null,
|
||||
type = CreateUserType.Normal
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
performLogin(
|
||||
username = testUsername,
|
||||
password = "encrypted-$testPassword"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tries login if External username taken`() = coroutinesTest {
|
||||
coEvery {
|
||||
performCreateExternalUser.invoke(
|
||||
email = testEmail,
|
||||
password = any(),
|
||||
referrer = null
|
||||
)
|
||||
} throws usernameTakenError
|
||||
|
||||
coEvery { performLogin.invoke(testEmail, any()) } returns mockk {
|
||||
every { userId } returns testUser.userId
|
||||
}
|
||||
|
||||
// GIVEN
|
||||
viewModel.currentAccountType = AccountType.External
|
||||
viewModel.externalEmail = testEmail
|
||||
viewModel.setPassword(testPassword)
|
||||
viewModel.userCreationState.test {
|
||||
// WHEN
|
||||
viewModel.startCreateUserWorkflow()
|
||||
// THEN
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Idle)
|
||||
assertTrue(awaitItem() is SignupViewModel.State.Processing)
|
||||
val successItem = awaitItem()
|
||||
assertTrue(successItem is SignupViewModel.State.Success)
|
||||
assertEquals(testUser.userId.id, successItem.userId)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
performCreateExternalUser(
|
||||
email = testEmail,
|
||||
password = "encrypted-$testPassword",
|
||||
referrer = null
|
||||
)
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
performLogin(
|
||||
username = testEmail,
|
||||
password = "encrypted-$testPassword"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ plugins {
|
|||
kotlin("android")
|
||||
}
|
||||
|
||||
libVersion = Version(1, 19, 1)
|
||||
libVersion = Version(1, 19, 2)
|
||||
|
||||
android()
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ import me.proton.core.contact.domain.repository.ContactRemoteDataSource
|
|||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.network.data.ApiProvider
|
||||
import me.proton.core.network.data.ProtonErrorException
|
||||
import me.proton.core.network.data.ResponseCodes
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.network.domain.onError
|
||||
import me.proton.core.network.domain.onSuccess
|
||||
import javax.inject.Inject
|
||||
|
|
|
@ -23,7 +23,7 @@ plugins {
|
|||
kotlin("android")
|
||||
}
|
||||
|
||||
libVersion = Version(1, 15, 7)
|
||||
libVersion = Version(1, 15, 8)
|
||||
|
||||
android()
|
||||
|
||||
|
|
|
@ -98,6 +98,9 @@ class ApiManagerFactory(
|
|||
|
||||
@VisibleForTesting
|
||||
val baseOkHttpClient by lazy {
|
||||
require(apiClient.timeoutSeconds >= ApiClient.MIN_TIMEOUT_SECONDS) {
|
||||
"Minimum timeout for ApiClient is ${ApiClient.MIN_TIMEOUT_SECONDS} seconds."
|
||||
}
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cache(cache())
|
||||
.connectTimeout(apiClient.timeoutSeconds, TimeUnit.SECONDS)
|
||||
|
|
|
@ -20,6 +20,7 @@ package me.proton.core.network.data.mapper
|
|||
|
||||
import me.proton.core.network.data.protonApi.Details
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.network.domain.handlers.HumanVerificationNeededHandler
|
||||
import me.proton.core.network.domain.humanverification.HumanVerificationAvailableMethods
|
||||
import me.proton.core.network.domain.humanverification.VerificationMethod
|
||||
|
@ -43,7 +44,7 @@ fun Details.toHumanVerificationEntity(): HumanVerificationAvailableMethods =
|
|||
|
||||
fun ApiResult.Error.ProtonData.parseDetails(errorCode: Int, details: Details?): ApiResult.Error.ProtonData {
|
||||
when (errorCode) {
|
||||
HumanVerificationNeededHandler.ERROR_CODE_HUMAN_VERIFICATION -> {
|
||||
ResponseCodes.HUMAN_VERIFICATION_REQUIRED -> {
|
||||
humanVerification = details?.toHumanVerificationEntity()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ package me.proton.core.network.data.protonApi
|
|||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import me.proton.core.network.data.ResponseCodes
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
|
||||
@Serializable
|
||||
data class GenericResponse(
|
||||
|
|
|
@ -45,6 +45,7 @@ import me.proton.core.network.domain.DohProvider
|
|||
import me.proton.core.network.domain.DohService
|
||||
import me.proton.core.network.domain.NetworkPrefs
|
||||
import me.proton.core.network.domain.NetworkStatus
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.network.domain.handlers.ProtonForceUpdateHandler
|
||||
import me.proton.core.network.domain.handlers.RefreshTokenHandler
|
||||
import me.proton.core.network.domain.client.ClientId
|
||||
|
@ -297,7 +298,7 @@ internal class ApiManagerTests {
|
|||
coEvery { backend.invoke<TestResult>(any()) } returns
|
||||
ApiResult.Error.Http(
|
||||
400, "",
|
||||
ApiResult.Error.ProtonData(ProtonForceUpdateHandler.ERROR_CODE_FORCE_UPDATE_APP_TOO_OLD, "")
|
||||
ApiResult.Error.ProtonData(ResponseCodes.APP_VERSION_BAD, "")
|
||||
)
|
||||
val result = apiManager.invoke { test() }
|
||||
assertTrue(result is ApiResult.Error)
|
||||
|
@ -309,7 +310,7 @@ internal class ApiManagerTests {
|
|||
coEvery { backend.invoke<TestResult>(any()) } returns
|
||||
ApiResult.Error.Http(
|
||||
400, "",
|
||||
ApiResult.Error.ProtonData(ProtonForceUpdateHandler.ERROR_CODE_FORCE_UPDATE_API_TOO_OLD, "")
|
||||
ApiResult.Error.ProtonData(ResponseCodes.API_VERSION_INVALID, "")
|
||||
)
|
||||
val result = apiManager.invoke { test() }
|
||||
assertTrue(result is ApiResult.Error)
|
||||
|
|
|
@ -42,9 +42,10 @@ interface ApiClient {
|
|||
|
||||
/**
|
||||
* Timeout for internal api call attempt (due to error handling logic there might be internal
|
||||
* calls in a single API call by the client.
|
||||
* calls in a single API call by the client).
|
||||
* The returned value must be greater than or equal to [MIN_TIMEOUT_SECONDS].
|
||||
*/
|
||||
val timeoutSeconds: Long get() = 10L
|
||||
val timeoutSeconds: Long get() = MIN_TIMEOUT_SECONDS
|
||||
|
||||
/**
|
||||
* Global timeout for DoH logic.
|
||||
|
@ -88,4 +89,8 @@ interface ApiClient {
|
|||
* @param errorMessage the localized error message the user should see.
|
||||
*/
|
||||
fun forceUpdate(errorMessage: String)
|
||||
|
||||
companion object {
|
||||
const val MIN_TIMEOUT_SECONDS = 30L
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.network.domain
|
||||
|
||||
/**
|
||||
* Contains general constants response codes.
|
||||
*
|
||||
* @author Dino Kadrikj.
|
||||
*/
|
||||
object ResponseCodes {
|
||||
const val OK = 1000
|
||||
const val NOT_ALLOWED = 2011
|
||||
const val NOT_EXISTS = 2501
|
||||
const val APP_VERSION_BAD = 5003
|
||||
const val API_VERSION_INVALID = 5005
|
||||
const val HUMAN_VERIFICATION_REQUIRED = 9001
|
||||
const val USER_CREATE_NAME_INVALID = 12_081
|
||||
const val USER_CREATE_TOKEN_INVALID = 12_087
|
||||
const val PAYMENTS_SUBSCRIPTION_NOT_EXISTS = 22_110
|
||||
}
|
|
@ -22,6 +22,7 @@ import me.proton.core.network.domain.ApiBackend
|
|||
import me.proton.core.network.domain.ApiErrorHandler
|
||||
import me.proton.core.network.domain.ApiManager
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.network.domain.client.ClientIdProvider
|
||||
import me.proton.core.network.domain.humanverification.HumanVerificationListener
|
||||
import me.proton.core.network.domain.session.SessionId
|
||||
|
@ -44,15 +45,11 @@ class HumanVerificationInvalidHandler<Api>(
|
|||
val clientId = clientIdProvider.getClientId(sessionId) ?: return error
|
||||
|
||||
// Invalid verification code ?
|
||||
if (error is ApiResult.Error.Http && error.proton?.code == ERROR_CODE_HUMAN_VERIFICATION_INVALID_CODE) {
|
||||
if (error is ApiResult.Error.Http && error.proton?.code == ResponseCodes.USER_CREATE_TOKEN_INVALID) {
|
||||
humanVerificationListener.onHumanVerificationInvalid(clientId)
|
||||
// Directly retry (could raise 9001, and then be handled by HumanVerificationNeededHandler).
|
||||
return backend(call)
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ERROR_CODE_HUMAN_VERIFICATION_INVALID_CODE = 12087
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import me.proton.core.network.domain.ApiBackend
|
|||
import me.proton.core.network.domain.ApiErrorHandler
|
||||
import me.proton.core.network.domain.ApiManager
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes.HUMAN_VERIFICATION_REQUIRED
|
||||
import me.proton.core.network.domain.client.ClientIdProvider
|
||||
import me.proton.core.network.domain.client.ClientId
|
||||
import me.proton.core.network.domain.humanverification.HumanVerificationAvailableMethods
|
||||
|
@ -52,7 +53,7 @@ class HumanVerificationNeededHandler<Api>(
|
|||
val clientId = clientIdProvider.getClientId(sessionId) ?: return error
|
||||
|
||||
// Recoverable with human verification ?
|
||||
if (error !is ApiResult.Error.Http || error.proton?.code != ERROR_CODE_HUMAN_VERIFICATION) return error
|
||||
if (error !is ApiResult.Error.Http || error.proton?.code != HUMAN_VERIFICATION_REQUIRED) return error
|
||||
|
||||
// Do we have details ?
|
||||
val details = error.proton.humanVerification ?: return error
|
||||
|
@ -78,8 +79,6 @@ class HumanVerificationNeededHandler<Api>(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val ERROR_CODE_HUMAN_VERIFICATION = 9001
|
||||
|
||||
private val verificationDebounceMs = TimeUnit.SECONDS.toMillis(5)
|
||||
private val staticMutex: Mutex = Mutex()
|
||||
private val clientMutexMap: MutableMap<ClientId, Mutex> = HashMap()
|
||||
|
|
|
@ -22,6 +22,7 @@ import me.proton.core.network.domain.ApiClient
|
|||
import me.proton.core.network.domain.ApiErrorHandler
|
||||
import me.proton.core.network.domain.ApiManager
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
|
||||
/**
|
||||
* Handles force update response.
|
||||
|
@ -44,11 +45,9 @@ class ProtonForceUpdateHandler<Api>(private val apiClient: ApiClient) :
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val ERROR_CODE_FORCE_UPDATE_APP_TOO_OLD = 5003
|
||||
const val ERROR_CODE_FORCE_UPDATE_API_TOO_OLD = 5005
|
||||
private val ERROR_CODE_FORCE_UPDATE = listOf(
|
||||
ERROR_CODE_FORCE_UPDATE_APP_TOO_OLD,
|
||||
ERROR_CODE_FORCE_UPDATE_API_TOO_OLD
|
||||
ResponseCodes.APP_VERSION_BAD,
|
||||
ResponseCodes.API_VERSION_INVALID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ plugins {
|
|||
kotlin("android")
|
||||
}
|
||||
|
||||
libVersion = Version(1, 17, 3)
|
||||
libVersion = Version(1, 17, 4)
|
||||
|
||||
android()
|
||||
|
||||
|
|
|
@ -21,9 +21,9 @@ package me.proton.core.payment.domain.usecase
|
|||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes.PAYMENTS_SUBSCRIPTION_NOT_EXISTS
|
||||
import me.proton.core.payment.domain.entity.Subscription
|
||||
import me.proton.core.payment.domain.repository.PaymentsRepository
|
||||
import me.proton.core.util.kotlin.exhaustive
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
@ -40,15 +40,11 @@ class GetCurrentSubscription @Inject constructor(
|
|||
paymentsRepository.getSubscription(userId)
|
||||
} catch (exception: ApiException) {
|
||||
val error = exception.error
|
||||
if (error is ApiResult.Error.Http && error.proton?.code == NO_ACTIVE_SUBSCRIPTION) {
|
||||
if (error is ApiResult.Error.Http && error.proton?.code == PAYMENTS_SUBSCRIPTION_NOT_EXISTS) {
|
||||
return null
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NO_ACTIVE_SUBSCRIPTION = 22110
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import kotlinx.coroutines.test.runBlockingTest
|
|||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.session.SessionId
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
import me.proton.core.payment.domain.entity.Subscription
|
||||
import me.proton.core.payment.domain.repository.PaymentsRepository
|
||||
import org.junit.Before
|
||||
|
@ -99,7 +99,7 @@ class GetCurrentSubscriptionTest {
|
|||
httpCode = 123,
|
||||
"http error",
|
||||
ApiResult.Error.ProtonData(
|
||||
code = GetCurrentSubscription.NO_ACTIVE_SUBSCRIPTION,
|
||||
code = ResponseCodes.PAYMENTS_SUBSCRIPTION_NOT_EXISTS,
|
||||
error = "no active subscription"
|
||||
)
|
||||
)
|
||||
|
|
|
@ -24,7 +24,7 @@ plugins {
|
|||
kotlin("plugin.serialization")
|
||||
}
|
||||
|
||||
libVersion = Version(1, 15, 2)
|
||||
libVersion = Version(1, 15, 3)
|
||||
|
||||
dependencies {
|
||||
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.util.kotlin
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
|
||||
/**
|
||||
* Catches an error that matches a given [predicate], and performs an [action] if such an error is caught.
|
||||
* If an error from upstream doesn't match the [predicate], it's passed downstream.
|
||||
*/
|
||||
inline fun <T> Flow<T>.catchWhen(
|
||||
crossinline predicate: suspend (Throwable) -> Boolean,
|
||||
crossinline action: suspend FlowCollector<T>.() -> Unit
|
||||
): Flow<T> {
|
||||
return catch { error ->
|
||||
if (predicate(error)) {
|
||||
action()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the collection of the flow, in case an exception occurred in upstream flow, and [predicate] returns `true`.
|
||||
* If the flow will be retried, [onBeforeRetryAction] will be called before, with a [Throwable] that caused the retry.
|
||||
* @see retryWhen
|
||||
*/
|
||||
inline fun <T> Flow<T>.retryOnceWhen(
|
||||
crossinline predicate: suspend (Throwable) -> Boolean,
|
||||
crossinline onBeforeRetryAction: suspend (cause: Throwable) -> Unit
|
||||
): Flow<T> {
|
||||
return retryWhen { cause, attempt ->
|
||||
val willRetry = predicate(cause) && attempt < 1
|
||||
if (willRetry) onBeforeRetryAction.invoke(cause)
|
||||
willRetry
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.util.kotlin
|
||||
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class FlowUtilsTest {
|
||||
@Test
|
||||
fun `catches known error`() = runBlockingTest {
|
||||
flow {
|
||||
emit(1)
|
||||
throw TestError
|
||||
}.catchWhen({ it is TestError }) {
|
||||
emit(2)
|
||||
}.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals(2, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not catch unknown error`() = runBlockingTest {
|
||||
flow {
|
||||
emit(1)
|
||||
error("random error")
|
||||
}.catchWhen({ it is TestError }) {
|
||||
emit(2)
|
||||
}.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals("random error", awaitError().message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-catches unknown error`() = runBlockingTest {
|
||||
flow {
|
||||
emit(1)
|
||||
error("random error")
|
||||
}.catchWhen({ it is TestError }) {
|
||||
emit(2)
|
||||
}.catch {
|
||||
emit(3)
|
||||
}.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals(3, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries after detecting known error`() = runBlockingTest {
|
||||
var retryCount = 0
|
||||
flow {
|
||||
emit(1)
|
||||
throw TestError
|
||||
}.retryOnceWhen({ it is TestError }) {
|
||||
retryCount += 1
|
||||
}.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals(TestError, awaitError())
|
||||
}
|
||||
assertEquals(1, retryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not retry after encountering unknown error`() = runBlockingTest {
|
||||
var retryCount = 0
|
||||
flow {
|
||||
emit(1)
|
||||
error("random error")
|
||||
}.retryOnceWhen({ it is TestError }) {
|
||||
retryCount += 1
|
||||
}.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals("random error", awaitError().message)
|
||||
}
|
||||
assertEquals(0, retryCount)
|
||||
}
|
||||
|
||||
private object TestError : Throwable()
|
||||
}
|
Loading…
Reference in New Issue