Add timeouts to operations that could hang (#3553)

This commit is contained in:
David Perez 2024-07-19 11:05:24 -05:00 committed by GitHub
parent 7fbc6ea4f3
commit 1fdfbac7b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 266 additions and 9 deletions

View file

@ -6,11 +6,22 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.subtitle import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import kotlinx.coroutines.flow.first
/**
* The duration, in milliseconds, we should wait while waiting for the vault status to not be
* 'UNLOCKING' before proceeding.
*/
private const val VAULT_LOCKED_TIMEOUT_MS: Long = 500L
/**
* The duration, in milliseconds, we should wait while retrieving ciphers before proceeding.
*/
private const val GET_CIPHERS_TIMEOUT_MS: Long = 5_000L
/** /**
* The default [AutofillCipherProvider] implementation. This service is used for getting current * The default [AutofillCipherProvider] implementation. This service is used for getting current
@ -28,9 +39,11 @@ class AutofillCipherProviderImpl(
// Wait for any unlocking actions to finish. This can be relevant on startup for Never lock // Wait for any unlocking actions to finish. This can be relevant on startup for Never lock
// accounts. // accounts.
vaultRepository.vaultUnlockDataStateFlow.first { vaultRepository
it.statusFor(userId) != VaultUnlockData.Status.UNLOCKING .vaultUnlockDataStateFlow
} .firstWithTimeoutOrNull(timeMillis = VAULT_LOCKED_TIMEOUT_MS) {
it.statusFor(userId = userId) != VaultUnlockData.Status.UNLOCKING
}
return !vaultRepository.isVaultUnlocked(userId = userId) return !vaultRepository.isVaultUnlocked(userId = userId)
} }
@ -105,6 +118,6 @@ class AutofillCipherProviderImpl(
vaultRepository vaultRepository
.ciphersStateFlow .ciphersStateFlow
.takeUnless { isVaultLocked() } .takeUnless { isVaultLocked() }
?.first { it.data != null } ?.firstWithTimeoutOrNull(timeMillis = GET_CIPHERS_TIMEOUT_MS) { it.data != null }
?.data ?.data
} }

View file

@ -5,6 +5,7 @@ import com.bitwarden.vault.CipherView
import com.bitwarden.vault.LoginUriView import com.bitwarden.vault.LoginUriView
import com.bitwarden.vault.UriMatchType import com.bitwarden.vault.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.getDomainOrNull import com.x8bit.bitwarden.data.platform.util.getDomainOrNull
import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull
import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull
@ -13,7 +14,6 @@ import com.x8bit.bitwarden.data.platform.util.regexOrNull
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.toSdkUriMatchType import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.toSdkUriMatchType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlin.text.RegexOption import kotlin.text.RegexOption
import kotlin.text.isNullOrBlank import kotlin.text.isNullOrBlank
@ -21,6 +21,11 @@ import kotlin.text.lowercase
import kotlin.text.matches import kotlin.text.matches
import kotlin.text.startsWith import kotlin.text.startsWith
/**
* The duration, in milliseconds, we should wait while retrieving domain data before we proceed.
*/
private const val GET_DOMAINS_TIMEOUT_MS: Long = 1_000L
/** /**
* The default [CipherMatchingManager] implementation. This class is responsible for matching * The default [CipherMatchingManager] implementation. This class is responsible for matching
* ciphers based on special criteria. * ciphers based on special criteria.
@ -37,7 +42,8 @@ class CipherMatchingManagerImpl(
val equivalentDomainsData = vaultRepository val equivalentDomainsData = vaultRepository
.domainsStateFlow .domainsStateFlow
.mapNotNull { it.data } .mapNotNull { it.data }
.first() .firstWithTimeoutOrNull(timeMillis = GET_DOMAINS_TIMEOUT_MS)
?: return emptyList()
val isAndroidApp = matchUri.isAndroidApp() val isAndroidApp = matchUri.isAndroidApp()
val defaultUriMatchType = settingsRepository.defaultUriMatchType.toSdkUriMatchType() val defaultUriMatchType = settingsRepository.defaultUriMatchType.toSdkUriMatchType()

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.platform.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/**
* Returns the first element emitted by the [Flow] or `null` if the operation exceeds the given
* timeout of [timeMillis].
*/
suspend fun <T> Flow<T>.firstWithTimeoutOrNull(
timeMillis: Long,
): T? = withTimeoutOrNull(timeMillis = timeMillis) { first() }
/**
* Returns the first element emitted by the [Flow] matching the given [predicate] or `null` if the
* operation exceeds the given timeout of [timeMillis].
*/
suspend fun <T> Flow<T>.firstWithTimeoutOrNull(
timeMillis: Long,
predicate: suspend (T) -> Boolean,
): T? = withTimeoutOrNull(timeMillis = timeMillis) { first(predicate) }

View file

@ -21,6 +21,7 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -31,6 +32,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AutofillCipherProviderTest { class AutofillCipherProviderTest {
private val cardView: CardView = mockk { private val cardView: CardView = mockk {
every { cardholderName } returns CARD_CARDHOLDER_NAME every { cardholderName } returns CARD_CARDHOLDER_NAME
@ -139,7 +141,7 @@ class AutofillCipherProviderTest {
autofillCipherProvider.isVaultLocked() autofillCipherProvider.isVaultLocked()
} }
testScheduler.advanceUntilIdle() testScheduler.runCurrent()
assertFalse(result.isCompleted) assertFalse(result.isCompleted)
mutableVaultStateFlow.value = listOf( mutableVaultStateFlow.value = listOf(
@ -155,6 +157,53 @@ class AutofillCipherProviderTest {
assertFalse(result.await()) assertFalse(result.await())
} }
@Suppress("MaxLineLength")
@Test
fun `isVaultLocked when there is an active user should wait for pending unlocking to finish and return the locked state for that user when it times out`() =
runTest {
every { authRepository.activeUserId } returns ACTIVE_USER_ID
mutableVaultStateFlow.value = listOf(
VaultUnlockData(
userId = ACTIVE_USER_ID,
status = VaultUnlockData.Status.UNLOCKING,
),
)
val result = async {
autofillCipherProvider.isVaultLocked()
}
testScheduler.runCurrent()
assertFalse(result.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = 1_000L)
testScheduler.runCurrent()
assertTrue(result.isCompleted)
assertTrue(result.await())
}
@Suppress("MaxLineLength")
@Test
fun `getCardAutofillCiphers when unlocked should return empty list when retrieving ciphers times out`() =
runTest {
coEvery { vaultRepository.isVaultUnlocked(ACTIVE_USER_ID) } returns true
mutableCiphersStateFlow.value = DataState.Loading
// Test
val actual = async {
autofillCipherProvider.getCardAutofillCiphers()
}
testScheduler.runCurrent()
assertFalse(actual.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = 5_000L)
testScheduler.runCurrent()
// Verify
assertTrue(actual.isCompleted)
assertEquals(emptyList<AutofillCipher.Card>(), actual.await())
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `getCardAutofillCiphers when unlocked should return non-null, non-deleted, and non-reprompt card ciphers`() = fun `getCardAutofillCiphers when unlocked should return non-null, non-deleted, and non-reprompt card ciphers`() =
@ -205,6 +254,28 @@ class AutofillCipherProviderTest {
assertEquals(emptyList<AutofillCipher.Card>(), actual) assertEquals(emptyList<AutofillCipher.Card>(), actual)
} }
@Suppress("MaxLineLength")
@Test
fun `getLoginAutofillCiphers when unlocked should return empty list when retrieving ciphers times out`() =
runTest {
coEvery { vaultRepository.isVaultUnlocked(ACTIVE_USER_ID) } returns true
mutableCiphersStateFlow.value = DataState.Loading
// Test
val actual = async {
autofillCipherProvider.getLoginAutofillCiphers(uri = URI)
}
testScheduler.runCurrent()
assertFalse(actual.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = 5_000L)
testScheduler.runCurrent()
// Verify
assertTrue(actual.isCompleted)
assertEquals(emptyList<AutofillCipher.Login>(), actual.await())
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `getLoginAutofillCiphers when unlocked should return matched, non-deleted, non-reprompt, login ciphers`() = fun `getLoginAutofillCiphers when unlocked should return matched, non-deleted, non-reprompt, login ciphers`() =

View file

@ -17,13 +17,18 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkStatic import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CipherMatchingManagerTest { class CipherMatchingManagerTest {
private lateinit var cipherMatchingManager: CipherMatchingManager private lateinit var cipherMatchingManager: CipherMatchingManager
@ -32,8 +37,11 @@ class CipherMatchingManagerTest {
private val settingsRepository: SettingsRepository = mockk { private val settingsRepository: SettingsRepository = mockk {
every { defaultUriMatchType } returns DEFAULT_URI_MATCH_TYPE every { defaultUriMatchType } returns DEFAULT_URI_MATCH_TYPE
} }
private val mutableDomainsStateFlow = MutableStateFlow<DataState<DomainsData>>(
value = DataState.Loaded(DOMAINS_DATA),
)
private val vaultRepository: VaultRepository = mockk { private val vaultRepository: VaultRepository = mockk {
every { domainsStateFlow } returns MutableStateFlow(DataState.Loaded(DOMAINS_DATA)) every { domainsStateFlow } returns mutableDomainsStateFlow
} }
// Setup test ciphers // Setup test ciphers
@ -179,6 +187,31 @@ class CipherMatchingManagerTest {
) )
} }
@Test
fun `filterCiphersForMatches should return an empty list when retrieving domains times out`() =
runTest {
// Setup
val uri = "google.com"
mutableDomainsStateFlow.value = DataState.Loading
// Test
val actual = async {
cipherMatchingManager.filterCiphersForMatches(
ciphers = ciphers,
matchUri = uri,
)
}
testScheduler.runCurrent()
assertFalse(actual.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = 1_000L)
testScheduler.runCurrent()
// Verify
assertTrue(actual.isCompleted)
assertEquals(emptyList<CipherView>(), actual.await())
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `filterCiphersForMatches should perform cipher matching when is android app and matching URI`() = fun `filterCiphersForMatches should perform cipher matching when is android app and matching URI`() =

View file

@ -0,0 +1,112 @@
package com.x8bit.bitwarden.data.platform.util
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FlowExtensionsTest {
@Test
fun `firstWithTimeoutOrNull should return null when the defined timeout has be reached`() =
runTest {
val timeout = 1_000L
val mutableSharedFlow = bufferedMutableSharedFlow<Unit>()
val result = async {
mutableSharedFlow.firstWithTimeoutOrNull(timeMillis = timeout)
}
testScheduler.runCurrent()
assertFalse(result.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = timeout)
testScheduler.runCurrent()
assertTrue(result.isCompleted)
assertNull(result.await())
}
@Suppress("MaxLineLength")
@Test
fun `firstWithTimeoutOrNull should return a value when the value is emitted before the defined timeout has be reached`() =
runTest {
val timeout = 1_000L
val mutableSharedFlow = bufferedMutableSharedFlow<Unit>()
val result = async {
mutableSharedFlow.firstWithTimeoutOrNull(timeMillis = timeout)
}
testScheduler.runCurrent()
assertFalse(result.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = timeout / 2)
testScheduler.runCurrent()
assertFalse(result.isCompleted)
mutableSharedFlow.tryEmit(Unit)
testScheduler.runCurrent()
assertTrue(result.isCompleted)
assertNotNull(result.await())
}
@Suppress("MaxLineLength")
@Test
fun `firstWithTimeoutOrNull with predicate should return null when the defined timeout has be reached`() =
runTest {
val timeout = 1_000L
val mutableSharedFlow = bufferedMutableSharedFlow<Boolean>()
val result = async {
mutableSharedFlow.firstWithTimeoutOrNull(timeMillis = timeout) { it }
}
testScheduler.runCurrent()
assertFalse(result.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = timeout / 2)
testScheduler.runCurrent()
assertFalse(result.isCompleted)
mutableSharedFlow.tryEmit(false)
testScheduler.advanceTimeBy(delayTimeMillis = timeout)
testScheduler.runCurrent()
assertTrue(result.isCompleted)
assertNull(result.await())
}
@Suppress("MaxLineLength")
@Test
fun `firstWithTimeoutOrNull with predicate should return a value when the value is emitted before the defined timeout has be reached`() =
runTest {
val timeout = 1_000L
val mutableSharedFlow = bufferedMutableSharedFlow<Boolean>()
val result = async {
mutableSharedFlow.firstWithTimeoutOrNull(timeMillis = timeout) { it }
}
testScheduler.runCurrent()
assertFalse(result.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = timeout / 2)
testScheduler.runCurrent()
assertFalse(result.isCompleted)
mutableSharedFlow.tryEmit(false)
testScheduler.runCurrent()
assertFalse(result.isCompleted)
mutableSharedFlow.tryEmit(true)
testScheduler.runCurrent()
assertTrue(result.isCompleted)
assertNotNull(result.await())
}
}