mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 13:29:18 -05:00
Compare commits
6 Commits
main
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a9c596b2 | ||
|
|
e33c4df59c | ||
|
|
7f426f1037 | ||
|
|
9bde261007 | ||
|
|
37b336ee35 | ||
|
|
c9d28941c6 |
@@ -22,4 +22,9 @@ interface CookieDiskSource {
|
||||
* @param config The [CookieConfigurationData] to persist, or `null` to delete.
|
||||
*/
|
||||
fun storeCookieConfig(hostname: String, config: CookieConfigurationData?)
|
||||
|
||||
/**
|
||||
* Clears all stored cookie configurations across all hostnames.
|
||||
*/
|
||||
fun clearCookies()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import com.bitwarden.core.data.util.decodeFromStringOrNull
|
||||
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val CONFIG_PREFIX = "elb_cookie_config"
|
||||
private const val ENCRYPTED_PREFIX = "bwSecureStorage:$CONFIG_PREFIX"
|
||||
|
||||
/**
|
||||
* Implementation of [CookieDiskSource] using encrypted SharedPreferences.
|
||||
@@ -15,7 +17,7 @@ private const val CONFIG_PREFIX = "elb_cookie_config"
|
||||
*/
|
||||
class CookieDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
private val encryptedSharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : CookieDiskSource,
|
||||
BaseEncryptedDiskSource(
|
||||
@@ -33,4 +35,14 @@ class CookieDiskSourceImpl(
|
||||
val key = CONFIG_PREFIX.appendIdentifier(hostname)
|
||||
putEncryptedString(key, config?.let { json.encodeToString(it) })
|
||||
}
|
||||
|
||||
override fun clearCookies() {
|
||||
val keysToRemove = encryptedSharedPreferences
|
||||
.all
|
||||
.keys
|
||||
.filter { it.startsWith(ENCRYPTED_PREFIX) }
|
||||
encryptedSharedPreferences.edit {
|
||||
keysToRemove.forEach { key -> remove(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,9 @@ interface DebugMenuRepository {
|
||||
* @param userStateUpdateTrigger A passable lambda to trigger a user state update.
|
||||
*/
|
||||
fun modifyStateToShowOnboardingCarousel(userStateUpdateTrigger: () -> Unit)
|
||||
|
||||
/**
|
||||
* Clears all stored SSO cookie configurations.
|
||||
*/
|
||||
fun clearSsoCookies()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
|
||||
@@ -20,6 +21,7 @@ class DebugMenuRepositoryImpl(
|
||||
private val serverConfigRepository: ServerConfigRepository,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val cookieDiskSource: CookieDiskSource,
|
||||
) : DebugMenuRepository {
|
||||
|
||||
private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow<Unit>(replay = 1)
|
||||
@@ -68,4 +70,8 @@ class DebugMenuRepositoryImpl(
|
||||
settingsDiskSource.hasUserLoggedInOrCreatedAccount = false
|
||||
userStateUpdateTrigger.invoke()
|
||||
}
|
||||
|
||||
override fun clearSsoCookies() {
|
||||
cookieDiskSource.clearCookies()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
@@ -92,10 +93,12 @@ object PlatformRepositoryModule {
|
||||
serverConfigRepository: ServerConfigRepository,
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
cookieDiskSource: CookieDiskSource,
|
||||
): DebugMenuRepository = DebugMenuRepositoryImpl(
|
||||
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
|
||||
serverConfigRepository = serverConfigRepository,
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
|
||||
/**
|
||||
* Returns a user-friendly error message if this [Throwable] is an allow-listed
|
||||
* exception type that carries one, or `null` otherwise.
|
||||
*/
|
||||
val Throwable.userFriendlyMessage: String?
|
||||
get() = when (this) {
|
||||
is CookieRedirectException -> message
|
||||
else -> null
|
||||
}
|
||||
@@ -11,7 +11,11 @@ sealed class ArchiveCipherResult {
|
||||
data object Success : ArchiveCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while archiving a cipher.
|
||||
* Generic error while archiving a cipher. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : ArchiveCipherResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : ArchiveCipherResult()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ sealed class CreateFolderResult {
|
||||
data class Success(val folderView: FolderView) : CreateFolderResult()
|
||||
|
||||
/**
|
||||
* Generic error while creating a folder.
|
||||
* Generic error while creating a folder. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : CreateFolderResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : CreateFolderResult()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ sealed class DeleteAttachmentResult {
|
||||
data object Success : DeleteAttachmentResult()
|
||||
|
||||
/**
|
||||
* Generic error while deleting an attachment.
|
||||
* Generic error while deleting an attachment. The optional [errorMessage] may be
|
||||
* displayed directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : DeleteAttachmentResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : DeleteAttachmentResult()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ sealed class DeleteCipherResult {
|
||||
data object Success : DeleteCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while deleting a cipher.
|
||||
* Generic error while deleting a cipher. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : DeleteCipherResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : DeleteCipherResult()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ sealed class DeleteFolderResult {
|
||||
data object Success : DeleteFolderResult()
|
||||
|
||||
/**
|
||||
* Generic error while deleting a folder.
|
||||
* Generic error while deleting a folder. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : DeleteFolderResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : DeleteFolderResult()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ sealed class DeleteSendResult {
|
||||
data object Success : DeleteSendResult()
|
||||
|
||||
/**
|
||||
* Generic error while deleting a send.
|
||||
* Generic error while deleting a send. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : DeleteSendResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : DeleteSendResult()
|
||||
}
|
||||
|
||||
@@ -22,9 +22,13 @@ sealed class ImportCredentialsResult {
|
||||
data class SyncFailed(val error: Throwable) : ImportCredentialsResult()
|
||||
|
||||
/**
|
||||
* Indicates there was an error importing the vault data.
|
||||
* Indicates there was an error importing the vault data. The optional [errorMessage] may be
|
||||
* displayed directly in the UI when present.
|
||||
*
|
||||
* @param error The error that occurred during import.
|
||||
*/
|
||||
data class Error(val error: Throwable) : ImportCredentialsResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : ImportCredentialsResult()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ sealed class RestoreCipherResult {
|
||||
data object Success : RestoreCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while restoring a cipher.
|
||||
* Generic error while restoring a cipher. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : RestoreCipherResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : RestoreCipherResult()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ sealed class ShareCipherResult {
|
||||
data object Success : ShareCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while sharing cipher.
|
||||
* Generic error while sharing cipher. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : ShareCipherResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : ShareCipherResult()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ sealed class UnarchiveCipherResult {
|
||||
data object Success : UnarchiveCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while unarchiving a cipher.
|
||||
* Generic error while unarchiving a cipher. The optional [errorMessage] may be
|
||||
* displayed directly in the UI when present.
|
||||
*/
|
||||
data class Error(val error: Throwable) : UnarchiveCipherResult()
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = null,
|
||||
) : UnarchiveCipherResult()
|
||||
}
|
||||
|
||||
@@ -140,6 +140,19 @@ fun DebugMenuScreen(
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(Modifier.height(height = 8.dp))
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(BitwardenString.clear_sso_cookies),
|
||||
onClick = {
|
||||
viewModel.trySendAction(
|
||||
DebugMenuAction.ClearSsoCookies,
|
||||
)
|
||||
},
|
||||
isEnabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(Modifier.height(height = 16.dp))
|
||||
BitwardenHorizontalDivider()
|
||||
Spacer(Modifier.height(height = 16.dp))
|
||||
|
||||
@@ -64,6 +64,7 @@ class DebugMenuViewModel @Inject constructor(
|
||||
DebugMenuAction.GenerateCrashClick -> handleCrashClick()
|
||||
DebugMenuAction.GenerateErrorReportClick -> handleErrorReportClick()
|
||||
DebugMenuAction.TriggerCookieAcquisition -> handleTriggerCookieAcquisition()
|
||||
DebugMenuAction.ClearSsoCookies -> handleClearSsoCookies()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +101,10 @@ class DebugMenuViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearSsoCookies() {
|
||||
debugMenuRepository.clearSsoCookies()
|
||||
}
|
||||
|
||||
private fun handleTriggerCookieAcquisition() {
|
||||
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
|
||||
data = CookieAcquisitionRequest(
|
||||
@@ -196,6 +201,11 @@ sealed class DebugMenuAction {
|
||||
*/
|
||||
data object TriggerCookieAcquisition : DebugMenuAction()
|
||||
|
||||
/**
|
||||
* The user has clicked clear SSO cookies button.
|
||||
*/
|
||||
data object ClearSsoCookies : DebugMenuAction()
|
||||
|
||||
/**
|
||||
* Internal actions not triggered from the UI.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.ui.platform.glide
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.network.ssl.createMtlsOkHttpClient
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
@@ -9,6 +10,7 @@ import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -24,6 +26,7 @@ import java.io.InputStream
|
||||
*
|
||||
* The configuration mirrors the SSL setup used in RetrofitsImpl for API calls.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@GlideModule
|
||||
class BitwardenAppGlideModule : AppGlideModule() {
|
||||
|
||||
@@ -37,6 +40,11 @@ class BitwardenAppGlideModule : AppGlideModule() {
|
||||
* Provides access to the [CertificateManager] for mTLS certificate management.
|
||||
*/
|
||||
fun certificateManager(): CertificateManager
|
||||
|
||||
/**
|
||||
* Provides access to the [NetworkCookieManager] for cookie-based authentication.
|
||||
*/
|
||||
fun networkCookieManager(): NetworkCookieManager
|
||||
}
|
||||
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
@@ -46,12 +54,20 @@ class BitwardenAppGlideModule : AppGlideModule() {
|
||||
entryPoint = BitwardenGlideEntryPoint::class.java,
|
||||
)
|
||||
val certificateManager = entryPoint.certificateManager()
|
||||
val networkCookieManager = entryPoint.networkCookieManager()
|
||||
|
||||
// Register OkHttpUrlLoader that uses our mTLS OkHttpClient
|
||||
// Build OkHttpClient with mTLS and cookie support
|
||||
val client = certificateManager
|
||||
.createMtlsOkHttpClient()
|
||||
.newBuilder()
|
||||
.addNetworkInterceptor(GlideCookieInterceptor(networkCookieManager))
|
||||
.build()
|
||||
|
||||
// Register OkHttpUrlLoader that uses our mTLS + cookie OkHttpClient
|
||||
registry.replace(
|
||||
GlideUrl::class.java,
|
||||
InputStream::class.java,
|
||||
OkHttpUrlLoader.Factory(certificateManager.createMtlsOkHttpClient()),
|
||||
OkHttpUrlLoader.Factory(client),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.x8bit.bitwarden.ui.platform.glide
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import com.bitwarden.network.provider.CookieProvider
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
private const val HEADER_COOKIE = "Cookie"
|
||||
private const val HTTP_302 = 302
|
||||
|
||||
/**
|
||||
* Interceptor that attaches cookies to Glide image requests for enterprise environments
|
||||
* requiring cookie-based authentication.
|
||||
*
|
||||
* Unlike [com.bitwarden.network.interceptor.CookieInterceptor], this interceptor does not
|
||||
* trigger cookie acquisition. It throws [CookieRedirectException] on HTTP 302 responses
|
||||
* to prevent Glide from following redirects and caching invalid content.
|
||||
*
|
||||
* @property cookieProvider Provider for retrieving cookies by hostname.
|
||||
*/
|
||||
class GlideCookieInterceptor(
|
||||
private val cookieProvider: CookieProvider,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val hostname = originalRequest.url.host
|
||||
val cookieHeader = cookieProvider
|
||||
.getCookies(hostname)
|
||||
.joinToString("; ") { "${it.name}=${it.value}" }
|
||||
|
||||
if (cookieHeader.isEmpty()) return chain.proceed(originalRequest).also(::check302)
|
||||
|
||||
val request = originalRequest
|
||||
.newBuilder()
|
||||
.header(HEADER_COOKIE, cookieHeader)
|
||||
.build()
|
||||
return chain.proceed(request).also(::check302)
|
||||
}
|
||||
|
||||
private fun check302(response: Response) {
|
||||
if (response.code == HTTP_302) {
|
||||
response.close()
|
||||
throw CookieRedirectException(
|
||||
hostname = response.request.url.host,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
|
||||
@@ -1190,7 +1191,8 @@ class VaultViewModel @Inject constructor(
|
||||
isPremium = state.isPremium,
|
||||
hasMasterPassword = state.hasMasterPassword,
|
||||
errorTitle = BitwardenString.an_error_has_occurred.asText(),
|
||||
errorMessage = BitwardenString.generic_error_message.asText(),
|
||||
errorMessage = vaultData.error.userFriendlyMessage?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
isRefreshing = false,
|
||||
restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds,
|
||||
isArchiveEnabled = state.isArchiveEnabled,
|
||||
|
||||
@@ -138,6 +138,38 @@ class CookieDiskSourceTest {
|
||||
assertEquals(config2, cookieDiskSource.getCookieConfig(hostname2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCookies should remove all stored cookie configs`() {
|
||||
val hostname1 = "vault.bitwarden.com"
|
||||
val hostname2 = "other.bitwarden.com"
|
||||
val config1 = CookieConfigurationData(
|
||||
hostname = hostname1,
|
||||
cookies = listOf(
|
||||
CookieConfigurationData.Cookie(name = "A", value = "1"),
|
||||
),
|
||||
)
|
||||
val config2 = CookieConfigurationData(
|
||||
hostname = hostname2,
|
||||
cookies = listOf(
|
||||
CookieConfigurationData.Cookie(name = "B", value = "2"),
|
||||
),
|
||||
)
|
||||
|
||||
cookieDiskSource.storeCookieConfig(hostname1, config1)
|
||||
cookieDiskSource.storeCookieConfig(hostname2, config2)
|
||||
|
||||
cookieDiskSource.clearCookies()
|
||||
|
||||
assertNull(cookieDiskSource.getCookieConfig(hostname1))
|
||||
assertNull(cookieDiskSource.getCookieConfig(hostname2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCookies should be safe to call when no cookies are stored`() {
|
||||
cookieDiskSource.clearCookies()
|
||||
assertNull(cookieDiskSource.getCookieConfig("vault.bitwarden.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storage should isolate configs by hostname`() {
|
||||
val hostname1 = "vault.bitwarden.com"
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import io.mockk.every
|
||||
@@ -42,11 +43,16 @@ class DebugMenuRepositoryTest {
|
||||
every { hasUserLoggedInOrCreatedAccount = any() } just runs
|
||||
}
|
||||
|
||||
private val mockCookieDiskSource = mockk<CookieDiskSource> {
|
||||
every { clearCookies() } just runs
|
||||
}
|
||||
|
||||
private val debugMenuRepository = DebugMenuRepositoryImpl(
|
||||
featureFlagOverrideDiskSource = mockFeatureFlagOverrideDiskSource,
|
||||
serverConfigRepository = mockServerConfigRepository,
|
||||
settingsDiskSource = mockSettingsDiskSource,
|
||||
authDiskSource = mockAuthDiskSource,
|
||||
cookieDiskSource = mockCookieDiskSource,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -169,6 +175,13 @@ class DebugMenuRepositoryTest {
|
||||
mockSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
|
||||
}
|
||||
}
|
||||
@Test
|
||||
fun `clearSsoCookies should call clearCookies on CookieDiskSource`() {
|
||||
debugMenuRepository.clearSsoCookies()
|
||||
verify(exactly = 1) {
|
||||
mockCookieDiskSource.clearCookies()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TEST_STRING_VALUE = "test"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
|
||||
class ThrowableExtensionsTest {
|
||||
|
||||
@Test
|
||||
fun `userFriendlyMessage should return message for CookieRedirectException`() {
|
||||
val exception = CookieRedirectException(hostname = "example.com")
|
||||
assertEquals(
|
||||
"Your request was interrupted because the app needed to " +
|
||||
"re-authenticate. Please try again.",
|
||||
exception.userFriendlyMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `userFriendlyMessage should return null for IOException`() {
|
||||
val exception = IOException("io error")
|
||||
assertNull(exception.userFriendlyMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `userFriendlyMessage should return null for RuntimeException`() {
|
||||
val exception = RuntimeException("runtime error")
|
||||
assertNull(exception.userFriendlyMessage)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,16 @@ class DebugMenuScreenTest : BitwardenComposeTest() {
|
||||
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear SSO cookies should send ClearSsoCookies action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Clear SSO cookies")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ClearSsoCookies) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset all coach mark tours should send ResetCoachMarkTourStatuses action`() {
|
||||
composeTestRule
|
||||
|
||||
@@ -145,6 +145,15 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClearSsoCookies should call clearSsoCookies on DebugMenuRepository`() {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(DebugMenuAction.ClearSsoCookies)
|
||||
verify(exactly = 1) {
|
||||
mockDebugMenuRepository.clearSsoCookies()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TriggerCookieAcquisition should set pending cookie acquisition`() =
|
||||
runTest {
|
||||
|
||||
@@ -44,4 +44,17 @@ class BitwardenAppGlideModuleTest {
|
||||
|
||||
assertTrue(hasCertificateManagerMethod)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BitwardenGlideEntryPoint should declare networkCookieManager method`() {
|
||||
val entryPointInterface = BitwardenAppGlideModule::class.java
|
||||
.declaredClasses
|
||||
.firstOrNull { it.simpleName == "BitwardenGlideEntryPoint" }
|
||||
|
||||
val methods = requireNotNull(entryPointInterface).declaredMethods
|
||||
val hasNetworkCookieManagerMethod =
|
||||
methods.any { it.name == "networkCookieManager" }
|
||||
|
||||
assertTrue(hasNetworkCookieManagerMethod)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.x8bit.bitwarden.ui.platform.glide
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import com.bitwarden.network.interceptor.FakeInterceptorChain
|
||||
import com.bitwarden.network.model.NetworkCookie
|
||||
import com.bitwarden.network.provider.CookieProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
class GlideCookieInterceptorTest {
|
||||
|
||||
private val mockCookieProvider: CookieProvider = mockk()
|
||||
|
||||
private val interceptor = GlideCookieInterceptor(
|
||||
cookieProvider = mockCookieProvider,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `intercept should proceed without cookie header when no cookies available`() {
|
||||
val originalRequest = Request.Builder()
|
||||
.url("https://vault.bitwarden.com/icons/icon.png")
|
||||
.build()
|
||||
val chain = FakeInterceptorChain(originalRequest)
|
||||
|
||||
every {
|
||||
mockCookieProvider.getCookies("vault.bitwarden.com")
|
||||
} returns emptyList()
|
||||
|
||||
val response = interceptor.intercept(chain)
|
||||
|
||||
assertEquals(originalRequest, response.request)
|
||||
assertNull(response.request.header("Cookie"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should attach single cookie correctly`() {
|
||||
val originalRequest = Request.Builder()
|
||||
.url("https://vault.bitwarden.com/icons/icon.png")
|
||||
.build()
|
||||
val chain = FakeInterceptorChain(originalRequest)
|
||||
|
||||
every {
|
||||
mockCookieProvider.getCookies("vault.bitwarden.com")
|
||||
} returns listOf(
|
||||
NetworkCookie(name = "awselb", value = "session123"),
|
||||
)
|
||||
|
||||
val response = interceptor.intercept(chain)
|
||||
|
||||
assertEquals("awselb=session123", response.request.header("Cookie"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should attach multiple cookies in correct format`() {
|
||||
val originalRequest = Request.Builder()
|
||||
.url("https://vault.bitwarden.com/icons/icon.png")
|
||||
.build()
|
||||
val chain = FakeInterceptorChain(originalRequest)
|
||||
|
||||
every {
|
||||
mockCookieProvider.getCookies("vault.bitwarden.com")
|
||||
} returns listOf(
|
||||
NetworkCookie(name = "awselb", value = "session123"),
|
||||
NetworkCookie(name = "awselbcors", value = "cors456"),
|
||||
)
|
||||
|
||||
val response = interceptor.intercept(chain)
|
||||
|
||||
assertEquals(
|
||||
"awselb=session123; awselbcors=cors456",
|
||||
response.request.header("Cookie"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should throw CookieRedirectException on 302 response without cookies`() {
|
||||
val originalRequest = Request.Builder()
|
||||
.url("https://vault.bitwarden.com/icons/icon.png")
|
||||
.build()
|
||||
|
||||
val redirectResponse = Response.Builder()
|
||||
.code(302)
|
||||
.message("Found")
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.request(originalRequest)
|
||||
.header("Location", "https://idp.example.com/auth")
|
||||
.build()
|
||||
|
||||
val chain = FakeInterceptorChain(
|
||||
request = originalRequest,
|
||||
responseProvider = { redirectResponse },
|
||||
)
|
||||
|
||||
every {
|
||||
mockCookieProvider.getCookies("vault.bitwarden.com")
|
||||
} returns emptyList()
|
||||
|
||||
val exception = assertThrows<CookieRedirectException> {
|
||||
interceptor.intercept(chain)
|
||||
}
|
||||
|
||||
assertEquals("vault.bitwarden.com", exception.hostname)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should throw CookieRedirectException on 302 response with cookies`() {
|
||||
val originalRequest = Request.Builder()
|
||||
.url("https://vault.bitwarden.com/icons/icon.png")
|
||||
.build()
|
||||
|
||||
val redirectResponse = Response.Builder()
|
||||
.code(302)
|
||||
.message("Found")
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.request(originalRequest)
|
||||
.header("Location", "https://idp.example.com/auth")
|
||||
.build()
|
||||
|
||||
val chain = FakeInterceptorChain(
|
||||
request = originalRequest,
|
||||
responseProvider = { redirectResponse },
|
||||
)
|
||||
|
||||
every {
|
||||
mockCookieProvider.getCookies("vault.bitwarden.com")
|
||||
} returns listOf(
|
||||
NetworkCookie(name = "awselb", value = "session123"),
|
||||
)
|
||||
|
||||
val exception = assertThrows<CookieRedirectException> {
|
||||
interceptor.intercept(chain)
|
||||
}
|
||||
|
||||
assertEquals("vault.bitwarden.com", exception.hostname)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should not call needsBootstrap or acquireCookies`() {
|
||||
val originalRequest = Request.Builder()
|
||||
.url("https://vault.bitwarden.com/icons/icon.png")
|
||||
.build()
|
||||
val chain = FakeInterceptorChain(originalRequest)
|
||||
|
||||
every {
|
||||
mockCookieProvider.getCookies("vault.bitwarden.com")
|
||||
} returns emptyList()
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
verify(exactly = 0) {
|
||||
mockCookieProvider.needsBootstrap(any())
|
||||
mockCookieProvider.acquireCookies(any())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.data.repository.util.baseIconUrl
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.createMockPolicy
|
||||
@@ -1387,6 +1388,31 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with CookieRedirectException should show user-friendly message`() =
|
||||
runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
error = CookieRedirectException(hostname = "example.com"),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertEquals(
|
||||
createMockVaultState(
|
||||
viewState = VaultState.ViewState.Error(
|
||||
message = (
|
||||
"Your request was interrupted because the app needed to " +
|
||||
"re-authenticate. Please try again."
|
||||
).asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with items should update state to Content and show an error dialog`() =
|
||||
@@ -1455,6 +1481,77 @@ class VaultViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with CookieRedirectException with items should show user-friendly error dialog`() =
|
||||
runTest {
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
error = CookieRedirectException(hostname = "example.com"),
|
||||
data = VaultData(
|
||||
decryptCipherListResult = createMockDecryptCipherListResult(
|
||||
number = 1,
|
||||
successes = listOf(createMockCipherListView(number = 1)),
|
||||
),
|
||||
collectionViewList = listOf(createMockCollectionView(number = 1)),
|
||||
folderViewList = listOf(createMockFolderView(number = 1)),
|
||||
sendViewList = listOf(createMockSendView(number = 1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertEquals(
|
||||
createMockVaultState(
|
||||
viewState = VaultState.ViewState.Content(
|
||||
loginItemsCount = 1,
|
||||
cardItemsCount = 0,
|
||||
identityItemsCount = 0,
|
||||
secureNoteItemsCount = 0,
|
||||
favoriteItems = listOf(),
|
||||
folderItems = listOf(
|
||||
VaultState.ViewState.FolderItem(
|
||||
id = "mockId-1",
|
||||
name = "mockName-1".asText(),
|
||||
itemCount = 1,
|
||||
),
|
||||
VaultState.ViewState.FolderItem(
|
||||
id = null,
|
||||
name = BitwardenString.folder_none.asText(),
|
||||
itemCount = 0,
|
||||
),
|
||||
),
|
||||
collectionItems = listOf(
|
||||
VaultState.ViewState.CollectionItem(
|
||||
id = "mockId-1",
|
||||
name = "mockName-1",
|
||||
itemCount = 1,
|
||||
),
|
||||
),
|
||||
noFolderItems = listOf(),
|
||||
trashItemsCount = 0,
|
||||
totpItemsCount = 1,
|
||||
itemTypesCount = 5,
|
||||
sshKeyItemsCount = 0,
|
||||
archivedItemsCount = 0,
|
||||
archiveEnabled = true,
|
||||
archiveSubText = null,
|
||||
archiveEndIcon = null,
|
||||
showCardGroup = true,
|
||||
),
|
||||
dialog = VaultState.DialogState.Error(
|
||||
title = BitwardenString.an_error_has_occurred.asText(),
|
||||
message = (
|
||||
"Your request was interrupted because the app needed to " +
|
||||
"re-authenticate. Please try again."
|
||||
).asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with empty items should update state to NoItems and show an error dialog`() =
|
||||
|
||||
@@ -13,4 +13,6 @@ import java.io.IOException
|
||||
*/
|
||||
class CookieRedirectException(
|
||||
val hostname: String,
|
||||
) : IOException("HTTP 302 redirect detected for $hostname.")
|
||||
) : IOException(
|
||||
"Your request was interrupted because the app needed to re-authenticate. Please try again.",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
@@ -45,8 +48,26 @@ sealed class BitwardenError {
|
||||
*/
|
||||
fun Throwable.toBitwardenError(): BitwardenError {
|
||||
return when (this) {
|
||||
// CookieRedirectException is a subclass of IOException thrown when SSO cookies
|
||||
// expire in a load-balanced environment. It must be checked before IOException to
|
||||
// avoid being classified as a generic Network error. We synthesize an Http error
|
||||
// with a JSON body so the exception's message propagates through the existing
|
||||
// parseErrorBodyOrNull pipeline used by service-layer recoverCatching blocks.
|
||||
is CookieRedirectException -> {
|
||||
BitwardenError.Http(
|
||||
throwable = HttpException(
|
||||
Response.error<Any>(
|
||||
HTTP_CODE_BAD_REQUEST,
|
||||
"""{"message": "${this.message}"}""".toResponseBody(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is IOException -> BitwardenError.Network(this)
|
||||
is HttpException -> BitwardenError.Http(this)
|
||||
else -> BitwardenError.Other(this)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HTTP_CODE_BAD_REQUEST: Int = 400
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class BitwardenErrorTest {
|
||||
|
||||
@Test
|
||||
fun `toBitwardenError with CookieRedirectException should return Http with status 400`() {
|
||||
val exception = CookieRedirectException(hostname = "example.com")
|
||||
|
||||
val result = exception.toBitwardenError()
|
||||
|
||||
assertTrue(result is BitwardenError.Http)
|
||||
val httpError = result as BitwardenError.Http
|
||||
assertEquals(400, httpError.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toBitwardenError with CookieRedirectException should include message in body`() {
|
||||
val exception = CookieRedirectException(hostname = "example.com")
|
||||
|
||||
val result = exception.toBitwardenError()
|
||||
|
||||
val httpError = result as BitwardenError.Http
|
||||
val body = httpError.responseBodyString
|
||||
assertTrue(body?.contains(exception.message.orEmpty()) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toBitwardenError with IOException should return Network`() {
|
||||
val exception = IOException("network failure")
|
||||
|
||||
val result = exception.toBitwardenError()
|
||||
|
||||
assertTrue(result is BitwardenError.Network)
|
||||
assertEquals(exception, result.throwable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toBitwardenError with HttpException should return Http`() {
|
||||
val exception = HttpException(
|
||||
Response.error<Unit>(400, "error".toResponseBody()),
|
||||
)
|
||||
|
||||
val result = exception.toBitwardenError()
|
||||
|
||||
assertTrue(result is BitwardenError.Http)
|
||||
assertEquals(exception, result.throwable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toBitwardenError with RuntimeException should return Other`() {
|
||||
val exception = RuntimeException("unexpected")
|
||||
|
||||
val result = exception.toBitwardenError()
|
||||
|
||||
assertTrue(result is BitwardenError.Other)
|
||||
assertEquals(exception, result.throwable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.bitwarden.network.util
|
||||
|
||||
import com.bitwarden.network.exception.CookieRedirectException
|
||||
import com.bitwarden.network.model.BitwardenError
|
||||
import com.bitwarden.network.model.CreateCipherResponseJson
|
||||
import com.bitwarden.network.model.toBitwardenError
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class ExceptionExtensionsTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseErrorBodyOrNull with CookieRedirectException should extract message`() {
|
||||
val expectedMessage = "Your request was interrupted because the app " +
|
||||
"needed to re-authenticate. Please try again."
|
||||
val error = CookieRedirectException(hostname = "example.com")
|
||||
.toBitwardenError()
|
||||
|
||||
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
|
||||
codes = listOf(NetworkErrorCode.BAD_REQUEST),
|
||||
json = json,
|
||||
)
|
||||
|
||||
assertEquals(expectedMessage, result?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseErrorBodyOrNull with Http and matching code should parse body`() {
|
||||
val responseBody = """
|
||||
{
|
||||
"message": "Bad request",
|
||||
"validationErrors": {
|
||||
"Name": ["Name is required"]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val error = BitwardenError.Http(
|
||||
throwable = HttpException(
|
||||
Response.error<Unit>(400, responseBody.toResponseBody()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
|
||||
codes = listOf(NetworkErrorCode.BAD_REQUEST),
|
||||
json = json,
|
||||
)
|
||||
|
||||
assertEquals("Bad request", result?.message)
|
||||
assertEquals(
|
||||
mapOf("Name" to listOf("Name is required")),
|
||||
result?.validationErrors,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseErrorBodyOrNull with Http and non-matching code should return null`() {
|
||||
val responseBody = """
|
||||
{
|
||||
"message": "Bad request",
|
||||
"validationErrors": null
|
||||
}
|
||||
""".trimIndent()
|
||||
val error = BitwardenError.Http(
|
||||
throwable = HttpException(
|
||||
Response.error<Unit>(400, responseBody.toResponseBody()),
|
||||
),
|
||||
)
|
||||
|
||||
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
|
||||
codes = listOf(NetworkErrorCode.UNAUTHORIZED),
|
||||
json = json,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseErrorBodyOrNull with Network should return null`() {
|
||||
val error = BitwardenError.Network(throwable = IOException("timeout"))
|
||||
|
||||
val result = error.parseErrorBodyOrNull<CreateCipherResponseJson.Invalid>(
|
||||
codes = listOf(NetworkErrorCode.BAD_REQUEST),
|
||||
json = json,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
<string name="archive_items">Archive Items</string>
|
||||
<string name="send_email_verification">Send Email Verification</string>
|
||||
<string name="trigger_cookie_acquisition">Trigger cookie acquisition</string>
|
||||
<string name="clear_sso_cookies">Clear SSO cookies</string>
|
||||
|
||||
<!-- endregion Debug Menu -->
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user