From d71150d572054d61dbfb597d69cd46c8670b417f Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Mon, 12 Jan 2026 16:29:04 -0500 Subject: [PATCH] [PM-30703] Update Credential Exchange to alpha04 and introduce payload parser This commit updates the Credential Exchange (CXF) implementation to align with the `androidx.credentials.providerevents:1.0.0-alpha04` library. The primary objective is to adapt to the breaking changes introduced in this version, which simplifies the API and removes the custom JSON request/response structure in favor of direct parameters. A key part of this refactor is the introduction of a dedicated `CredentialExchangePayloadParser`. This parser is responsible for validating and processing the incoming JSON payload from the exporting credential manager. Behavioral changes: - The structure of the `ImportCredentialsRequest` sent to the credential provider has changed. It no longer uses a custom JSON string but instead passes `credentialTypes` and `knownExtensions` directly. - The response from the credential provider is no longer a nested JSON structure with a Base64 encoded payload. It is now a direct JSON object representing the exported data. Specific changes: - **Dependencies**: - Upgraded `androidx.credentials.providerevents` to `1.0.0-alpha04`. - Added Hilt dependencies to the `cxf` module for dependency injection. - **CXF Payload Parsing**: - Created `CredentialExchangePayloadParser` interface and its `CredentialExchangePayloadParserImpl` implementation to handle the parsing of incoming CXF JSON data. - Introduced a `CredentialExchangePayload` sealed class to represent the different outcomes of parsing: `Importable`, `NoItems`, and `Error`. - Added a `CxfModule` to provide the parser implementation via Hilt. - Added comprehensive unit tests for `CredentialExchangePayloadParserImpl` to cover valid payloads, version checks, invalid JSON, and error conditions. - **Import Logic**: - Refactored `CredentialExchangeImporterImpl` to use the new `ImportCredentialsRequest` constructor, passing `credentialTypes` and `knownExtensions` directly instead of building a JSON string. - Updated `CredentialExchangeImportManagerImpl` to use the new `CredentialExchangePayloadParser`. The manager now delegates payload validation and parsing, simplifying its own logic to focus on the import, upload, and sync process. - Removed the now-obsolete manual parsing of the two-layered CXP/CXF JSON structure and Base64 decoding. - **Export Logic**: - In `CredentialExchangeRegistryImpl`, added the `exportMatcher` WASM binary required by the `alpha04` API when registering an export flow. - Simplified `CredentialExchangeCompletionManagerImpl` to directly return the export data as a JSON string, removing the previous logic that wrapped it in a `CXP` protocol message with Base64 encoding. - **Testing**: - Updated unit tests across `app` and `cxf` modules (`CredentialExchangeImporterTest`, `MainViewModelTest`, `CredentialExchangeImportManagerTest`, etc.) to reflect the API changes in `ImportCredentialsRequest` and the new parsing flow. - Removed outdated test logic related to the old JSON structure and Base64 decoding. --- .../com/x8bit/bitwarden/MainViewModel.kt | 3 +- .../CredentialExchangeImportManagerImpl.kt | 197 ++--- .../vault/manager/di/VaultManagerModule.kt | 6 +- .../com/x8bit/bitwarden/MainViewModelTest.kt | 17 +- .../util/SpecialCircumstanceExtensionsTest.kt | 3 +- .../CredentialExchangeImportManagerTest.kt | 784 ++++++++---------- .../feature/rootnav/RootNavViewModelTest.kt | 6 +- .../exportitems/SelectAccountScreenTest.kt | 3 +- .../exportitems/SelectAccountViewModelTest.kt | 3 +- .../reviewexport/ReviewExportScreenTest.kt | 3 +- .../reviewexport/ReviewExportViewModelTest.kt | 3 +- cxf/build.gradle.kts | 4 + .../kotlin/com/bitwarden/cxf/di/CxfModule.kt | 24 + .../CredentialExchangeImporterImpl.kt | 67 +- ...CredentialExchangeCompletionManagerImpl.kt | 25 +- .../cxf/model/CredentialExchangePayload.kt | 25 + .../cxf/model/ImportCredentialsRequestData.kt | 6 +- .../parser/CredentialExchangePayloadParser.kt | 14 + .../CredentialExchangePayloadParserImpl.kt | 79 ++ .../CredentialExchangeRegistryImpl.kt | 14 + cxf/src/main/res/raw/export_matcher.bin | Bin 0 -> 146743 bytes .../CredentialExchangeImporterTest.kt | 86 +- .../CredentialExchangePayloadParserTest.kt | 197 +++++ gradle/libs.versions.toml | 2 +- 24 files changed, 868 insertions(+), 703 deletions(-) create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/di/CxfModule.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangePayload.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParser.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserImpl.kt create mode 100644 cxf/src/main/res/raw/export_matcher.bin create mode 100644 cxf/src/test/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt index 20b2438ae5..dfd1ffeca6 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt @@ -448,7 +448,8 @@ class MainViewModel @Inject constructor( SpecialCircumstance.CredentialExchangeExport( data = ImportCredentialsRequestData( uri = importCredentialsRequest.uri, - requestJson = importCredentialsRequest.request.requestJson, + credentialTypes = importCredentialsRequest.request.credentialTypes, + knownExtensions = importCredentialsRequest.request.knownExtensions, ), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt index 61c74ff52d..83504b8dfd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerImpl.kt @@ -1,17 +1,13 @@ package com.x8bit.bitwarden.data.vault.manager -import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException -import com.bitwarden.core.data.util.asFailure -import com.bitwarden.core.data.util.asSuccess -import com.bitwarden.core.data.util.decodeFromStringOrNull import com.bitwarden.core.data.util.flatMap -import com.bitwarden.cxf.model.CredentialExchangeExportResponse -import com.bitwarden.cxf.model.CredentialExchangeProtocolMessage +import com.bitwarden.cxf.model.CredentialExchangePayload +import com.bitwarden.cxf.parser.CredentialExchangePayloadParser import com.bitwarden.network.model.ImportCiphersJsonRequest import com.bitwarden.network.model.ImportCiphersResponseJson import com.bitwarden.network.service.CiphersService -import com.bitwarden.network.util.base64UrlDecodeOrNull +import com.bitwarden.vault.Cipher import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.util.hasRestrictItemTypes @@ -19,16 +15,6 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json - -private val SUPPORTED_CXP_FORMAT_VERSIONS = mapOf( - 0 to setOf(0), -) -private val SUPPORTED_CXF_FORMAT_VERSIONS = mapOf( - 0 to setOf(0), - 1 to setOf(0), -) /** * Default implementation of [CredentialExchangeImportManager]. @@ -38,118 +24,87 @@ class CredentialExchangeImportManagerImpl( private val ciphersService: CiphersService, private val vaultSyncManager: VaultSyncManager, private val policyManager: PolicyManager, - private val json: Json, + private val credentialExchangePayloadParser: CredentialExchangePayloadParser, ) : CredentialExchangeImportManager { - @Suppress("LongMethod") override suspend fun importCxfPayload( userId: String, payload: String, - ): ImportCxfPayloadResult { - val credentialExchangeExportResult = json - .decodeFromStringOrNull(payload) - ?: return ImportCxfPayloadResult.Error( - ImportCredentialsInvalidJsonException("Invalid CXP JSON."), - ) - - if (SUPPORTED_CXP_FORMAT_VERSIONS[credentialExchangeExportResult.version.major] - ?.contains(credentialExchangeExportResult.version.minor) != true - ) { - return ImportCxfPayloadResult.Error( - ImportCredentialsInvalidJsonException( - "Unsupported CXF version: ${credentialExchangeExportResult.version}.", - ), - ) - } - - val decodedPayload = credentialExchangeExportResult.payload - .base64UrlDecodeOrNull() - ?: return ImportCxfPayloadResult.Error( - ImportCredentialsInvalidJsonException("Unable to decode payload."), - ) - - val exportResponse = json - .decodeFromStringOrNull(decodedPayload) - ?: return ImportCxfPayloadResult.Error( - ImportCredentialsInvalidJsonException("Unable to decode header."), - ) - - if (SUPPORTED_CXF_FORMAT_VERSIONS[exportResponse.version.major] - ?.contains(exportResponse.version.minor) != true - ) { - return ImportCxfPayloadResult.Error( - ImportCredentialsInvalidJsonException("Unsupported CXF version."), - ) - } - - if (exportResponse.accounts.isEmpty()) { - return ImportCxfPayloadResult.NoItems - } - - val accountsJson = try { - json.encodeToString( - value = exportResponse.accounts.firstOrNull(), - ) - } catch (_: SerializationException) { - return ImportCxfPayloadResult.Error( - ImportCredentialsInvalidJsonException("Unable to re-encode accounts."), - ) - } - return vaultSdkSource - .importCxf( - userId = userId, - payload = accountsJson, - ) - .flatMap { cipherList -> - // Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active - val filteredCipherList = if (policyManager.hasRestrictItemTypes()) { - cipherList.filter { cipher -> cipher.type != CipherType.CARD } - } else { - cipherList - } - - if (filteredCipherList.isEmpty()) { - // If no ciphers were returned, we can skip the remaining steps and return the - // appropriate result. - return ImportCxfPayloadResult.NoItems - } - ciphersService - .importCiphers( - request = ImportCiphersJsonRequest( - ciphers = filteredCipherList.map { - it.toEncryptedNetworkCipher( - encryptedFor = userId, - ) - }, - folders = emptyList(), - folderRelationships = emptyList(), - ), - ) - .flatMap { importCiphersResponseJson -> - when (importCiphersResponseJson) { - is ImportCiphersResponseJson.Invalid -> { - ImportCredentialsUnknownErrorException().asFailure() - } - - ImportCiphersResponseJson.Success -> { - ImportCxfPayloadResult - .Success(itemCount = filteredCipherList.size) - .asSuccess() - } - } - } + ): ImportCxfPayloadResult = + when (val exportResponse = credentialExchangePayloadParser.parse(payload)) { + is CredentialExchangePayload.Importable -> { + import( + userId = userId, + accountsJson = exportResponse.accountsJson, + ) } - .map { - when (val syncResult = vaultSyncManager.syncForResult(forced = true)) { - is SyncVaultDataResult.Success -> it - is SyncVaultDataResult.Error -> { - ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable) + + CredentialExchangePayload.NoItems -> { + ImportCxfPayloadResult.NoItems + } + + is CredentialExchangePayload.Error -> { + ImportCxfPayloadResult.Error(exportResponse.throwable) + } + } + + private suspend fun import( + userId: String, + accountsJson: String, + ): ImportCxfPayloadResult = vaultSdkSource + .importCxf(userId = userId, payload = accountsJson) + .flatMap { cipherList -> + // Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active + val filteredCipherList = if (policyManager.hasRestrictItemTypes()) { + cipherList.filter { cipher -> cipher.type != CipherType.CARD } + } else { + cipherList + } + + if (filteredCipherList.isEmpty()) { + // If no ciphers were returned, we can skip the remaining steps and return the + // appropriate result. + return ImportCxfPayloadResult.NoItems + } + uploadCiphers(userId = userId, ciphers = filteredCipherList) + } + .map { syncVault(it) } + .fold( + onSuccess = { it }, + onFailure = { ImportCxfPayloadResult.Error(error = it) }, + ) + + private suspend fun uploadCiphers( + userId: String, + ciphers: List, + ): Result { + val request = ImportCiphersJsonRequest( + ciphers = ciphers.map { it.toEncryptedNetworkCipher(encryptedFor = userId) }, + folders = emptyList(), + folderRelationships = emptyList(), + ) + return ciphersService + .importCiphers(request) + .flatMap { response -> + when (response) { + is ImportCiphersResponseJson.Invalid -> { + Result.failure(ImportCredentialsUnknownErrorException()) + } + + is ImportCiphersResponseJson.Success -> { + Result.success( + ImportCxfPayloadResult.Success(itemCount = ciphers.size), + ) } } } - .fold( - onSuccess = { it }, - onFailure = { ImportCxfPayloadResult.Error(error = it) }, - ) } + + private suspend fun syncVault(result: ImportCxfPayloadResult): ImportCxfPayloadResult = + when (val syncResult = vaultSyncManager.syncForResult(forced = true)) { + is SyncVaultDataResult.Success -> result + is SyncVaultDataResult.Error -> { + ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable) + } + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 8eeb682871..1c63697fec 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.manager.di import android.content.Context import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.manager.realtime.RealtimeManager +import com.bitwarden.cxf.parser.CredentialExchangePayloadParser import com.bitwarden.data.manager.file.FileManager import com.bitwarden.network.service.CiphersService import com.bitwarden.network.service.FolderService @@ -49,7 +50,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json import java.time.Clock import javax.inject.Singleton @@ -252,12 +252,12 @@ object VaultManagerModule { ciphersService: CiphersService, vaultSyncManager: VaultSyncManager, policyManager: PolicyManager, - json: Json, + credentialExchangePayloadParser: CredentialExchangePayloadParser, ): CredentialExchangeImportManager = CredentialExchangeImportManagerImpl( vaultSdkSource = vaultSdkSource, ciphersService = ciphersService, vaultSyncManager = vaultSyncManager, policyManager = policyManager, - json = json, + credentialExchangePayloadParser = credentialExchangePayloadParser, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt index 1069c3d79a..b71e301fdf 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt @@ -82,14 +82,18 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkConstructor import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkConstructor import io.mockk.unmockkObject import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -242,6 +246,7 @@ class MainViewModelTest : BaseViewModelTest() { ProviderCreateCredentialRequest.Companion, ProviderGetCredentialRequest.Companion, ) + unmockkConstructor(JSONObject::class) } @Suppress("MaxLineLength") @@ -1124,9 +1129,16 @@ class MainViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `on ReceiveNewIntent with import credentials request data should set the special circumstance to CredentialExchangeExport`() { + mockkConstructor(JSONObject::class) + every { + anyConstructed().put(any(), any()) + } returns mockk() val viewModel = createViewModel() val importCredentialsRequestData = ProviderImportCredentialsRequest( - request = ImportCredentialsRequest("mockRequestJson"), + request = ImportCredentialsRequest( + setOf("mockCredentialType-1"), + setOf(), + ), callingAppInfo = mockk(), uri = mockk(), credId = "mockCredId", @@ -1145,7 +1157,8 @@ class MainViewModelTest : BaseViewModelTest() { SpecialCircumstance.CredentialExchangeExport( data = ImportCredentialsRequestData( uri = importCredentialsRequestData.uri, - requestJson = importCredentialsRequestData.request.requestJson, + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ), ), specialCircumstanceManager.specialCircumstance, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt index 31d7d0c5c9..740e73fded 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt @@ -354,7 +354,8 @@ class SpecialCircumstanceExtensionsTest { fun `toImportCredentialsRequestDataOrNull should return a non-null value for ImportCredentials`() { val importCredentialsRequestData = ImportCredentialsRequestData( uri = mockk(), - requestJson = "", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ) assertEquals( importCredentialsRequestData, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt index 4939a7badb..bb4216a572 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/CredentialExchangeImportManagerTest.kt @@ -1,18 +1,16 @@ package com.x8bit.bitwarden.data.vault.manager +import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess -import com.bitwarden.core.data.util.decodeFromStringOrNull -import com.bitwarden.cxf.model.CredentialExchangeExportResponse -import com.bitwarden.cxf.model.CredentialExchangeProtocolMessage -import com.bitwarden.cxf.model.CredentialExchangeVersion +import com.bitwarden.cxf.model.CredentialExchangePayload +import com.bitwarden.cxf.parser.CredentialExchangePayloadParser import com.bitwarden.network.model.ImportCiphersJsonRequest import com.bitwarden.network.model.ImportCiphersResponseJson import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.network.model.createMockPolicy import com.bitwarden.network.service.CiphersService -import com.bitwarden.network.util.base64UrlDecodeOrNull import com.bitwarden.vault.Cipher import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource @@ -25,17 +23,11 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.slot -import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull @@ -49,16 +41,10 @@ class CredentialExchangeImportManagerTest { getActivePolicies(any()) } returns emptyList() } - private val json = mockk { - every { - decodeFromStringOrNull(any()) - } returns DEFAULT_CXP_MESSAGE - every { - decodeFromStringOrNull(any()) - } returns DEFAULT_CXF_EXPORT_RESPONSE - every { - encodeToString(value = DEFAULT_ACCOUNT, serializer = any()) - } returns DEFAULT_ACCOUNT_JSON + private val credentialExchangePayloadParser: CredentialExchangePayloadParser = mockk { + every { parse(DEFAULT_PAYLOAD) } returns CredentialExchangePayload.Importable( + accountsJson = DEFAULT_ACCOUNT_JSON, + ) } private val importManager = CredentialExchangeImportManagerImpl( @@ -66,102 +52,148 @@ class CredentialExchangeImportManagerTest { ciphersService = ciphersService, vaultSyncManager = vaultSyncManager, policyManager = policyManager, - json = json, + credentialExchangePayloadParser = credentialExchangePayloadParser, ) - @BeforeEach - fun setUp() { - mockkStatic(String::base64UrlDecodeOrNull) - every { - DEFAULT_PAYLOAD.base64UrlDecodeOrNull() - } returns DEFAULT_PAYLOAD - } + @Nested + inner class ParserResultHandling { + @Test + fun `when parser returns Error, should return Error`() = runTest { + val parserException = ImportCredentialsInvalidJsonException("Invalid JSON") + every { + credentialExchangePayloadParser.parse(DEFAULT_PAYLOAD) + } returns CredentialExchangePayload.Error(parserException) - @AfterEach - fun tearDown() { - unmockkStatic(String::base64UrlDecodeOrNull) - } + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - @Test - fun `when vaultSdkSource importCxf fails, should return Error`() = runTest { - val exception = RuntimeException("SDK import failed") - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, - ) - } returns exception.asFailure() - - coEvery { - ciphersService.importCiphers(any()) - } just awaits - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertEquals(ImportCxfPayloadResult.Error(exception), result) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + assertTrue(result is ImportCxfPayloadResult.Error) + assertEquals(parserException, (result as ImportCxfPayloadResult.Error).error) } - coVerify(exactly = 0) { - ciphersService.importCiphers(any()) + + @Test + fun `when parser returns NoItems, should return NoItems`() = runTest { + every { + credentialExchangePayloadParser.parse(DEFAULT_PAYLOAD) + } returns CredentialExchangePayload.NoItems + + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + assertEquals(ImportCxfPayloadResult.NoItems, result) } } - @Test - fun `when ciphersService importCiphers fails, should return Error`() = runTest { - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, - ) - } returns DEFAULT_CIPHER_LIST.asSuccess() + @Nested + inner class ImportFlow { + @Test + fun `when vaultSdkSource importCxf fails, should return Error`() = runTest { + val exception = RuntimeException("SDK import failed") + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns exception.asFailure() - val exception = RuntimeException("Network import failed") - val capturedRequest = slot() - coEvery { - ciphersService.importCiphers(capture(capturedRequest)) - } returns exception.asFailure() + coEvery { + ciphersService.importCiphers(any()) + } just awaits - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - assertEquals(ImportCxfPayloadResult.Error(exception), result) - assertEquals(1, capturedRequest.captured.ciphers.size) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - ciphersService.importCiphers(any()) + assertEquals(ImportCxfPayloadResult.Error(exception), result) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + } + coVerify(exactly = 0) { + ciphersService.importCiphers(any()) + } } - } - @Test - fun `when ciphersService importCiphers returns Invalid, should return Error`() = runTest { - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, - ) - } returns DEFAULT_CIPHER_LIST.asSuccess() + @Test + fun `when ciphersService importCiphers fails, should return Error`() = runTest { + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns DEFAULT_CIPHER_LIST.asSuccess() - coEvery { - ciphersService.importCiphers(any()) - } returns ImportCiphersResponseJson - .Invalid(validationErrors = emptyMap()) - .asSuccess() + val exception = RuntimeException("Network import failed") + val capturedRequest = slot() + coEvery { + ciphersService.importCiphers(capture(capturedRequest)) + } returns exception.asFailure() - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - val error = (result as? ImportCxfPayloadResult.Error)?.error - assertNotNull(error) - assertTrue(error is ImportCredentialsUnknownErrorException) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - ciphersService.importCiphers(any()) + assertEquals(ImportCxfPayloadResult.Error(exception), result) + assertEquals(1, capturedRequest.captured.ciphers.size) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + ciphersService.importCiphers(any()) + } } - } - @Suppress("MaxLineLength") - @Test - fun `when ciphersService importCiphers is Success and sync fails should return SyncFailed`() = - runTest { + @Test + fun `when ciphersService importCiphers returns Invalid, should return Error`() = runTest { + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns DEFAULT_CIPHER_LIST.asSuccess() + + coEvery { + ciphersService.importCiphers(any()) + } returns ImportCiphersResponseJson + .Invalid(validationErrors = emptyMap()) + .asSuccess() + + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + val error = (result as? ImportCxfPayloadResult.Error)?.error + assertNotNull(error) + assertTrue(error is ImportCredentialsUnknownErrorException) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + ciphersService.importCiphers(any()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `when ciphersService importCiphers is Success and sync fails should return SyncFailed`() = + runTest { + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns DEFAULT_CIPHER_LIST.asSuccess() + + coEvery { + ciphersService.importCiphers(any()) + } returns ImportCiphersResponseJson.Success.asSuccess() + val throwable = Throwable("Error!") + coEvery { + vaultSyncManager.syncForResult(forced = true) + } returns SyncVaultDataResult.Error(throwable) + + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + assertEquals( + ImportCxfPayloadResult.SyncFailed(throwable), + result, + ) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + ciphersService.importCiphers(any()) + vaultSyncManager.syncForResult(forced = true) + } + } + + @Test + fun `when all steps succeed, should return Success`() = runTest { coEvery { vaultSdkSource.importCxf( userId = DEFAULT_USER_ID, @@ -172,219 +204,6 @@ class CredentialExchangeImportManagerTest { coEvery { ciphersService.importCiphers(any()) } returns ImportCiphersResponseJson.Success.asSuccess() - val throwable = Throwable("Error!") - coEvery { - vaultSyncManager.syncForResult(forced = true) - } returns SyncVaultDataResult.Error(throwable) - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - - assertEquals( - ImportCxfPayloadResult.SyncFailed(throwable), - result, - ) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - ciphersService.importCiphers(any()) - vaultSyncManager.syncForResult(forced = true) - } - } - - @Test - fun `when all steps succeed, should return Success`() = runTest { - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, - ) - } returns DEFAULT_CIPHER_LIST.asSuccess() - - coEvery { - ciphersService.importCiphers(any()) - } returns ImportCiphersResponseJson.Success.asSuccess() - coEvery { - vaultSyncManager.syncForResult(forced = true) - } returns SyncVaultDataResult.Success(itemsAvailable = true) - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - - assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - ciphersService.importCiphers(any()) - vaultSyncManager.syncForResult(forced = true) - } - } - - @Suppress("MaxLineLength") - @Test - fun `when importCxf returns empty cipher list, should skip importCiphers and sync and return NoItems`() = - runTest { - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, - ) - } returns emptyList().asSuccess() - coEvery { - ciphersService.importCiphers(any()) - } just awaits - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - - assertEquals(ImportCxfPayloadResult.NoItems, result) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - } - coVerify(exactly = 0) { - ciphersService.importCiphers(any()) - } - } - - @Suppress("MaxLineLength") - @Test - fun `when payload cannot be deserialized into CredentialExchangeProtocolMessage, should return Error`() = - runTest { - every { - json.decodeFromStringOrNull(any()) - } returns null - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - } - - @Test - fun `when payload version is not supported, should return Error`() = runTest { - // Verify unsupported major version returns Error - every { - json.decodeFromStringOrNull(any()) - } returns DEFAULT_CXP_MESSAGE.copy( - version = DEFAULT_CXP_VERSION.copy(major = 1), - ) - - var result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - - // Verify unsupported minor version returns Error - every { - json.decodeFromStringOrNull(any()) - } returns DEFAULT_CXP_MESSAGE.copy( - version = DEFAULT_CXP_VERSION.copy(minor = 1), - ) - - result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - } - - @Test - fun `when decodedPayload is null, should return Error`() = runTest { - every { - DEFAULT_CXP_MESSAGE.payload.base64UrlDecodeOrNull() - } returns null - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - } - - @Test - fun `when CredentialExchangeExportResponse json is invalid, should return Error`() = runTest { - every { - json.decodeFromStringOrNull(DEFAULT_PAYLOAD) - } returns null - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - } - - @Test - fun `when CredentialExchangeExportResponse version is not supported, should return Error`() = - runTest { - every { - json.decodeFromStringOrNull(DEFAULT_PAYLOAD) - } returns DEFAULT_CXF_EXPORT_RESPONSE.copy( - version = DEFAULT_CXF_VERSION.copy(major = 2), - ) - - var result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - - every { - json.decodeFromStringOrNull(DEFAULT_PAYLOAD) - } returns DEFAULT_CXF_EXPORT_RESPONSE.copy( - version = DEFAULT_CXF_VERSION.copy(minor = 1), - ) - - result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - } - - @Test - fun `when CredentialExchangeExportResponse accounts is empty, should return NoItems`() = - runTest { - every { - json.decodeFromStringOrNull(DEFAULT_PAYLOAD) - } returns DEFAULT_CXF_EXPORT_RESPONSE.copy( - accounts = emptyList(), - ) - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.NoItems) - } - - @Suppress("MaxLineLength") - @Test - fun `when CredentialExchangeExportResponse account cannot be serialized, should return Error`() = - runTest { - every { - json.encodeToString(DEFAULT_CXF_EXPORT_RESPONSE.accounts.firstOrNull()) - } throws SerializationException() - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertTrue(result is ImportCxfPayloadResult.Error) - } - - @Test - fun `when user has restrict item types policy, card ciphers should be filtered out`() = - runTest { - every { - policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) - } returns listOf( - createMockPolicy( - id = "mockId-1", - organizationId = "mockId-1", - type = PolicyTypeJson.RESTRICT_ITEM_TYPES, - isEnabled = true, - data = null, - ), - ) - - val loginCipher = createMockSdkCipher(number = 1) - val cardCipher = createMockSdkCipher(number = 2).copy( - type = com.bitwarden.vault.CipherType.CARD, - ) - val mixedCipherList = listOf(loginCipher, cardCipher) - - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, - ) - } returns mixedCipherList.asSuccess() - - val capturedRequest = slot() - coEvery { - ciphersService.importCiphers(capture(capturedRequest)) - } returns ImportCiphersResponseJson.Success.asSuccess() - coEvery { vaultSyncManager.syncForResult(forced = true) } returns SyncVaultDataResult.Success(itemsAvailable = true) @@ -392,8 +211,6 @@ class CredentialExchangeImportManagerTest { val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result) - // Verify only the login cipher was imported, card was filtered out - assertEquals(1, capturedRequest.captured.ciphers.size) coVerify(exactly = 1) { vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) ciphersService.importCiphers(any()) @@ -401,174 +218,221 @@ class CredentialExchangeImportManagerTest { } } - @Test - fun `when user has no restrict item types policy, card ciphers should not be filtered`() = - runTest { - every { - policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) - } returns emptyList() + @Suppress("MaxLineLength") + @Test + fun `when importCxf returns empty cipher list, should skip importCiphers and sync and return NoItems`() = + runTest { + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns emptyList().asSuccess() + coEvery { + ciphersService.importCiphers(any()) + } just awaits - val loginCipher = createMockSdkCipher(number = 1) - val cardCipher = createMockSdkCipher(number = 2).copy( - type = com.bitwarden.vault.CipherType.CARD, - ) - val mixedCipherList = listOf(loginCipher, cardCipher) + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, + assertEquals(ImportCxfPayloadResult.NoItems, result) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + } + coVerify(exactly = 0) { + ciphersService.importCiphers(any()) + } + } + } + + @Nested + inner class PolicyFiltering { + @Test + fun `when user has restrict item types policy, card ciphers should be filtered out`() = + runTest { + every { + policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns listOf( + createMockPolicy( + id = "mockId-1", + organizationId = "mockId-1", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), ) - } returns mixedCipherList.asSuccess() - val capturedRequest = slot() - coEvery { - ciphersService.importCiphers(capture(capturedRequest)) - } returns ImportCiphersResponseJson.Success.asSuccess() - - coEvery { - vaultSyncManager.syncForResult(forced = true) - } returns SyncVaultDataResult.Success(itemsAvailable = true) - - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) - - assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result) - // Verify both ciphers were imported - assertEquals(2, capturedRequest.captured.ciphers.size) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - ciphersService.importCiphers(any()) - vaultSyncManager.syncForResult(forced = true) - } - } - - @Test - fun `when user has restrict policy disabled, card ciphers should not be filtered`() = - runTest { - every { - policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) - } returns listOf( - createMockPolicy( - id = "mockId-1", - organizationId = "mockId-1", - type = PolicyTypeJson.RESTRICT_ITEM_TYPES, - isEnabled = false, - data = null, - ), - ) - - val loginCipher = createMockSdkCipher(number = 1) - val cardCipher = createMockSdkCipher(number = 2).copy( - type = com.bitwarden.vault.CipherType.CARD, - ) - val mixedCipherList = listOf(loginCipher, cardCipher) - - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, + val loginCipher = createMockSdkCipher(number = 1) + val cardCipher = createMockSdkCipher(number = 2).copy( + type = com.bitwarden.vault.CipherType.CARD, ) - } returns mixedCipherList.asSuccess() + val mixedCipherList = listOf(loginCipher, cardCipher) - val capturedRequest = slot() - coEvery { - ciphersService.importCiphers(capture(capturedRequest)) - } returns ImportCiphersResponseJson.Success.asSuccess() + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns mixedCipherList.asSuccess() - coEvery { - vaultSyncManager.syncForResult(forced = true) - } returns SyncVaultDataResult.Success(itemsAvailable = true) + val capturedRequest = slot() + coEvery { + ciphersService.importCiphers(capture(capturedRequest)) + } returns ImportCiphersResponseJson.Success.asSuccess() - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + coEvery { + vaultSyncManager.syncForResult(forced = true) + } returns SyncVaultDataResult.Success(itemsAvailable = true) - assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result) - // Verify both ciphers were imported when policy is disabled - assertEquals(2, capturedRequest.captured.ciphers.size) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) - ciphersService.importCiphers(any()) - vaultSyncManager.syncForResult(forced = true) + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + assertEquals(ImportCxfPayloadResult.Success(itemCount = 1), result) + // Verify only the login cipher was imported, card was filtered out + assertEquals(1, capturedRequest.captured.ciphers.size) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + ciphersService.importCiphers(any()) + vaultSyncManager.syncForResult(forced = true) + } } - } - @Test - fun `when user has restrict policy and all ciphers are cards, should return NoItems`() = - runTest { - every { - policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) - } returns listOf( - createMockPolicy( - id = "mockId-1", - organizationId = "mockId-1", - type = PolicyTypeJson.RESTRICT_ITEM_TYPES, - isEnabled = true, - data = null, - ), - ) + @Test + fun `when user has no restrict item types policy, card ciphers should not be filtered`() = + runTest { + every { + policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns emptyList() - val cardCipher1 = createMockSdkCipher(number = 1).copy( - type = com.bitwarden.vault.CipherType.CARD, - ) - val cardCipher2 = createMockSdkCipher(number = 2).copy( - type = com.bitwarden.vault.CipherType.CARD, - ) - val allCardsList = listOf(cardCipher1, cardCipher2) - - coEvery { - vaultSdkSource.importCxf( - userId = DEFAULT_USER_ID, - payload = DEFAULT_ACCOUNT_JSON, + val loginCipher = createMockSdkCipher(number = 1) + val cardCipher = createMockSdkCipher(number = 2).copy( + type = com.bitwarden.vault.CipherType.CARD, ) - } returns allCardsList.asSuccess() + val mixedCipherList = listOf(loginCipher, cardCipher) - val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns mixedCipherList.asSuccess() - assertEquals(ImportCxfPayloadResult.NoItems, result) - coVerify(exactly = 1) { - vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + val capturedRequest = slot() + coEvery { + ciphersService.importCiphers(capture(capturedRequest)) + } returns ImportCiphersResponseJson.Success.asSuccess() + + coEvery { + vaultSyncManager.syncForResult(forced = true) + } returns SyncVaultDataResult.Success(itemsAvailable = true) + + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result) + // Verify both ciphers were imported + assertEquals(2, capturedRequest.captured.ciphers.size) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + ciphersService.importCiphers(any()) + vaultSyncManager.syncForResult(forced = true) + } } - // Verify importCiphers was never called since all items were filtered - coVerify(exactly = 0) { - ciphersService.importCiphers(any()) + + @Test + fun `when user has restrict policy disabled, card ciphers should not be filtered`() = + runTest { + every { + policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns listOf( + createMockPolicy( + id = "mockId-1", + organizationId = "mockId-1", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = false, + data = null, + ), + ) + + val loginCipher = createMockSdkCipher(number = 1) + val cardCipher = createMockSdkCipher(number = 2).copy( + type = com.bitwarden.vault.CipherType.CARD, + ) + val mixedCipherList = listOf(loginCipher, cardCipher) + + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns mixedCipherList.asSuccess() + + val capturedRequest = slot() + coEvery { + ciphersService.importCiphers(capture(capturedRequest)) + } returns ImportCiphersResponseJson.Success.asSuccess() + + coEvery { + vaultSyncManager.syncForResult(forced = true) + } returns SyncVaultDataResult.Success(itemsAvailable = true) + + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + assertEquals(ImportCxfPayloadResult.Success(itemCount = 2), result) + // Verify both ciphers were imported when policy is disabled + assertEquals(2, capturedRequest.captured.ciphers.size) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + ciphersService.importCiphers(any()) + vaultSyncManager.syncForResult(forced = true) + } } - } + + @Test + fun `when user has restrict policy and all ciphers are cards, should return NoItems`() = + runTest { + every { + policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns listOf( + createMockPolicy( + id = "mockId-1", + organizationId = "mockId-1", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ) + + val cardCipher1 = createMockSdkCipher(number = 1).copy( + type = com.bitwarden.vault.CipherType.CARD, + ) + val cardCipher2 = createMockSdkCipher(number = 2).copy( + type = com.bitwarden.vault.CipherType.CARD, + ) + val allCardsList = listOf(cardCipher1, cardCipher2) + + coEvery { + vaultSdkSource.importCxf( + userId = DEFAULT_USER_ID, + payload = DEFAULT_ACCOUNT_JSON, + ) + } returns allCardsList.asSuccess() + + val result = importManager.importCxfPayload(DEFAULT_USER_ID, DEFAULT_PAYLOAD) + + assertEquals(ImportCxfPayloadResult.NoItems, result) + coVerify(exactly = 1) { + vaultSdkSource.importCxf(DEFAULT_USER_ID, DEFAULT_ACCOUNT_JSON) + } + // Verify importCiphers was never called since all items were filtered + coVerify(exactly = 0) { + ciphersService.importCiphers(any()) + } + } + } } private const val DEFAULT_USER_ID = "mockId-1" private const val DEFAULT_PAYLOAD = "mockPayload-1" private val DEFAULT_CIPHER: Cipher = createMockSdkCipher(number = 1) private val DEFAULT_CIPHER_LIST: List = listOf(DEFAULT_CIPHER) -private val DEFAULT_CXP_VERSION = CredentialExchangeVersion( - major = 0, - minor = 0, -) -private val DEFAULT_CXF_VERSION = CredentialExchangeVersion( - major = 1, - minor = 0, -) -private val DEFAULT_CXP_MESSAGE: CredentialExchangeProtocolMessage = - CredentialExchangeProtocolMessage( - version = DEFAULT_CXP_VERSION, - exporterRpId = "mockRpId-1", - exporterDisplayName = "mockDisplayName-1", - payload = DEFAULT_PAYLOAD, - ) -private val DEFAULT_ACCOUNT: CredentialExchangeExportResponse.Account = - CredentialExchangeExportResponse.Account( - id = "mockId-1", - username = "mockUsername-1", - email = "mockEmail-1", - collections = JsonArray(content = emptyList()), - items = JsonArray(content = emptyList()), - ) -private val DEFAULT_CXF_EXPORT_RESPONSE: CredentialExchangeExportResponse = - CredentialExchangeExportResponse( - version = DEFAULT_CXF_VERSION, - exporterRpId = "mockRpId-1", - exporterDisplayName = "mockDisplayName-1", - timestamp = 0, - accounts = listOf(DEFAULT_ACCOUNT), - ) private val DEFAULT_ACCOUNT_JSON = """ { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 3d88c55f84..89d5d33f23 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -1555,7 +1555,8 @@ class RootNavViewModelTest : BaseViewModelTest() { SpecialCircumstance.CredentialExchangeExport( data = ImportCredentialsRequestData( uri = mockk(), - requestJson = "mockRequestJson", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ), ) mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE) @@ -1573,7 +1574,8 @@ class RootNavViewModelTest : BaseViewModelTest() { SpecialCircumstance.CredentialExchangeExport( data = ImportCredentialsRequestData( uri = mockk(), - requestJson = "mockRequestJson", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ), ) mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt index bbe838ab5b..57b24b13aa 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountScreenTest.kt @@ -191,7 +191,8 @@ private val LOCKED_ACCOUNT_SUMMARY = AccountSummary( ) private val DEFAULT_IMPORT_REQUEST = ImportCredentialsRequestData( uri = mockk(), - requestJson = "mockRequestJson", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ) private val DEFAULT_STATE = SelectAccountState( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt index 98eb1318e6..22a5747adf 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/SelectAccountViewModelTest.kt @@ -302,5 +302,6 @@ private val DEFAULT_USER_STATE = UserState( ) private val DEFAULT_IMPORT_REQUEST = ImportCredentialsRequestData( uri = mockk(), - requestJson = "mockRequestJson", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt index bcd526e5f0..19e3e537b7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportScreenTest.kt @@ -200,7 +200,8 @@ private val DEFAULT_STATE = ReviewExportState( ), importCredentialsRequestData = ImportCredentialsRequestData( uri = Uri.EMPTY, - requestJson = "", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ), hasOtherAccounts = true, dialog = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt index 2a3e1b686b..ef2147498c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/reviewexport/ReviewExportViewModelTest.kt @@ -415,7 +415,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() { private val MOCK_URI = mockk() private val DEFAULT_REQUEST_DATA = ImportCredentialsRequestData( uri = MOCK_URI, - requestJson = "mockRequestJson", + credentialTypes = setOf("mockCredentialType-1"), + knownExtensions = setOf(), ) private val DEFAULT_CONTENT_VIEW_STATE = ReviewExportState.ViewState.Content( itemTypeCounts = ReviewExportState.ItemTypeCounts( diff --git a/cxf/build.gradle.kts b/cxf/build.gradle.kts index 01554521e6..27ae3ba814 100644 --- a/cxf/build.gradle.kts +++ b/cxf/build.gradle.kts @@ -3,9 +3,11 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) alias(libs.plugins.google.services) + alias(libs.plugins.hilt) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } android { @@ -52,6 +54,8 @@ dependencies { implementation(libs.androidx.credentials) implementation(libs.androidx.credentials.providerevents) implementation(libs.androidx.credentials.providerevents.play.services) + implementation(libs.google.hilt.android) + ksp(libs.google.hilt.compiler) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization) implementation(libs.timber) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/di/CxfModule.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/di/CxfModule.kt new file mode 100644 index 0000000000..40f96b6518 --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/di/CxfModule.kt @@ -0,0 +1,24 @@ +package com.bitwarden.cxf.di + +import com.bitwarden.cxf.parser.CredentialExchangePayloadParser +import com.bitwarden.cxf.parser.CredentialExchangePayloadParserImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json + +/** + * Provides dependencies from the CXF module. + */ +@Module +@InstallIn(SingletonComponent::class) +object CxfModule { + + @Provides + fun provideCredentialExchangePayloadParser( + json: Json, + ): CredentialExchangePayloadParser = CredentialExchangePayloadParserImpl( + json = json, + ) +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt index 7f524448ee..0a08cf43af 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt @@ -6,12 +6,10 @@ import androidx.credentials.providerevents.exception.ImportCredentialsCancellati import androidx.credentials.providerevents.exception.ImportCredentialsException import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException import androidx.credentials.providerevents.transfer.ImportCredentialsRequest +import androidx.credentials.providerevents.transfer.KnownExtensions import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult import timber.log.Timber -private const val CXP_FORMAT_VERSION_MAJOR = 0 -private const val CXP_FORMAT_VERSION_MINOR = 0 - /** * Default implementation of [CredentialExchangeImporter]. * @@ -28,45 +26,28 @@ internal class CredentialExchangeImporterImpl( override suspend fun importCredentials( credentialTypes: List, - ): ImportCredentialsSelectionResult { - return try { - val response = providerEventsManager.importCredentials( - context = activity, - request = ImportCredentialsRequest( - // Format the request according to the FIDO CXP spec. - // TODO: [PM-25663] Link to the correct documentation once it's available. - requestJson = """ - { - "version": { - "major":$CXP_FORMAT_VERSION_MAJOR, - "minor":$CXP_FORMAT_VERSION_MINOR - }, - "mode": ["direct"], - "importerRpId": "${activity.packageName}", - "importerDisplayName": "${activity.applicationInfo.name}", - "credentialTypes": [ - ${credentialTypes.joinToString { "\"$it\"" }} - ] - } - """ - .trimIndent(), - ), - ) - ImportCredentialsSelectionResult.Success( - response = response.response.responseJson, - callingAppInfo = response.callingAppInfo, - ) - } catch (e: ImportCredentialsCancellationException) { - Timber.e(e, "User cancelled import from selected credential manager.") - ImportCredentialsSelectionResult.Cancelled - } catch (e: ImportCredentialsException) { - Timber.e(e, "Failed to import items from selected credential manager.") - ImportCredentialsSelectionResult.Failure(error = e) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Timber.e(e, "Failed to import items from selected credential manager.") - ImportCredentialsSelectionResult.Failure( - error = ImportCredentialsUnknownErrorException(), - ) - } + ): ImportCredentialsSelectionResult = try { + val response = providerEventsManager.importCredentials( + context = activity, + request = ImportCredentialsRequest( + credentialTypes = credentialTypes.toSet(), + knownExtensions = setOf(KnownExtensions.KNOWN_EXTENSION_SHARED), + ), + ) + ImportCredentialsSelectionResult.Success( + response = response.response.responseJson, + callingAppInfo = response.callingAppInfo, + ) + } catch (e: ImportCredentialsCancellationException) { + Timber.e(e, "User cancelled import from selected credential manager.") + ImportCredentialsSelectionResult.Cancelled + } catch (e: ImportCredentialsException) { + Timber.e(e, "Failed to import items from selected credential manager.") + ImportCredentialsSelectionResult.Failure(error = e) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Timber.e(e, "Failed to import items from selected credential manager.") + ImportCredentialsSelectionResult.Failure( + error = ImportCredentialsUnknownErrorException(), + ) } } diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt index 7c6ad9f166..6588046e7e 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt @@ -7,10 +7,7 @@ import androidx.credentials.providerevents.exception.ImportCredentialsException import androidx.credentials.providerevents.transfer.ImportCredentialsResponse import com.bitwarden.cxf.manager.model.ExportCredentialsResult import java.time.Clock -import kotlin.io.encoding.Base64 -private const val CXP_FORMAT_VERSION_MAJOR = 0 -private const val CXP_FORMAT_VERSION_MINOR = 0 private const val CXF_FORMAT_VERSION_MAJOR = 1 private const val CXF_FORMAT_VERSION_MINOR = 0 @@ -32,9 +29,6 @@ internal class CredentialExchangeCompletionManagerImpl( } is ExportCredentialsResult.Success -> { - - val exporterRpId = activity.packageName - val exporterDisplayName = activity.applicationInfo.name val headerJson = """ { "version": { @@ -49,29 +43,12 @@ internal class CredentialExchangeCompletionManagerImpl( """ .trimIndent() - val encodedPayload = Base64.UrlSafe - .withPadding(Base64.PaddingOption.ABSENT) - .encode(headerJson.toByteArray()) - - val responseJson = """ - { - "version": { - "major": $CXP_FORMAT_VERSION_MAJOR, - "minor": $CXP_FORMAT_VERSION_MINOR - }, - "exporterRpId": "$exporterRpId", - "exporterDisplayName": "$exporterDisplayName", - "payload": "$encodedPayload" - } - """ - .trimIndent() - IntentHandler.setImportCredentialsResponse( context = activity, intent = intent, uri = exportResult.uri, response = ImportCredentialsResponse( - responseJson = responseJson, + responseJson = headerJson, ), ) } diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangePayload.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangePayload.kt new file mode 100644 index 0000000000..d13ee9f32b --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/model/CredentialExchangePayload.kt @@ -0,0 +1,25 @@ +package com.bitwarden.cxf.model + +/** + * Represents the result of parsing a CXF payload. + */ +sealed class CredentialExchangePayload { + /** + * Indicates that the payload is importable. + */ + data class Importable( + val accountsJson: String, + ) : CredentialExchangePayload() + + /** + * Indicates that the payload contains no importable items. + */ + data object NoItems : CredentialExchangePayload() + + /** + * An error occurred while parsing the payload. + */ + data class Error( + val throwable: Throwable, + ) : CredentialExchangePayload() +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/model/ImportCredentialsRequestData.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/model/ImportCredentialsRequestData.kt index 4b17558a9c..f1fa9930e7 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/model/ImportCredentialsRequestData.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/model/ImportCredentialsRequestData.kt @@ -8,10 +8,12 @@ import kotlinx.parcelize.Parcelize * A request to import the provider's credentials. * * @property uri the FileProvider uri that the importer will read the response from. - * @property requestJson the request to import the provider's credentials. + * @property credentialTypes the credential types that the requester supports. + * @property knownExtensions the known extensions that the importer supports. */ @Parcelize data class ImportCredentialsRequestData( val uri: Uri, - val requestJson: String, + val credentialTypes: Set, + val knownExtensions: Set, ) : Parcelable diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParser.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParser.kt new file mode 100644 index 0000000000..99c413f00b --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParser.kt @@ -0,0 +1,14 @@ +package com.bitwarden.cxf.parser + +import com.bitwarden.cxf.model.CredentialExchangePayload + +/** + * Parser for Credential Exchange Payload JSON strings. + */ +interface CredentialExchangePayloadParser { + + /** + * Parses a Credential Exchange Payload JSON string into a [CredentialExchangePayload]. + */ + fun parse(payload: String): CredentialExchangePayload +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserImpl.kt new file mode 100644 index 0000000000..50359a2534 --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserImpl.kt @@ -0,0 +1,79 @@ +package com.bitwarden.cxf.parser + +import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException +import com.bitwarden.core.data.util.decodeFromStringOrNull +import com.bitwarden.cxf.model.CredentialExchangeExportResponse +import com.bitwarden.cxf.model.CredentialExchangePayload +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +private val SUPPORTED_CXF_VERSIONS = mapOf( + 0 to setOf(0), + 1 to setOf(0), +) + +/** + * Default implementation of the [CredentialExchangePayloadParser]. + */ +internal class CredentialExchangePayloadParserImpl( + private val json: Json, +) : CredentialExchangePayloadParser { + + override fun parse(payload: String): CredentialExchangePayload = + parseInternal(payload) + ?: CredentialExchangePayload.Error( + ImportCredentialsInvalidJsonException( + "Invalid Credential Exchange JSON.", + ), + ) + + /** + * Attempts to parse the alpha04+ Credential Exchange API JSON payload into a + * [CredentialExchangePayload] object. + * + * @return A [CredentialExchangePayload] object if the payload can be serialized directly into a + * [CredentialExchangeExportResponse], otherwise `null`. + */ + private fun parseInternal(payload: String): CredentialExchangePayload? = + json + .decodeFromStringOrNull(payload) + ?.let { exportResponse -> + when { + !isCxfVersionSupported(exportResponse) -> { + CredentialExchangePayload.Error( + ImportCredentialsInvalidJsonException( + "Unsupported CXF version.", + ), + ) + } + + exportResponse.accounts.isEmpty() -> { + CredentialExchangePayload.NoItems + } + + else -> { + try { + // We only support single account import, silently ignore additional + // accounts. + val accountsJson = json.encodeToString( + value = exportResponse.accounts.first(), + ) + CredentialExchangePayload.Importable( + accountsJson = accountsJson, + ) + } catch (_: SerializationException) { + CredentialExchangePayload.Error( + ImportCredentialsInvalidJsonException( + "Unable to serialize accounts.", + ), + ) + } + } + } + } + + private fun isCxfVersionSupported( + response: CredentialExchangeExportResponse, + ): Boolean = SUPPORTED_CXF_VERSIONS[response.version.major] + ?.contains(response.version.minor) == true +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/registry/CredentialExchangeRegistryImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/registry/CredentialExchangeRegistryImpl.kt index b3750ae2bd..c690b2a07c 100644 --- a/cxf/src/main/kotlin/com/bitwarden/cxf/registry/CredentialExchangeRegistryImpl.kt +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/registry/CredentialExchangeRegistryImpl.kt @@ -11,6 +11,7 @@ import androidx.credentials.providerevents.transfer.RegisterExportResponse import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess +import com.bitwarden.cxf.R import com.bitwarden.cxf.registry.model.RegistrationRequest import timber.log.Timber import java.util.UUID @@ -25,6 +26,17 @@ internal class CredentialExchangeRegistryImpl( private val providerEventsManager: ProviderEventsManager = ProviderEventsManager.create(application) + /** + * This is the default wasm binary provided by Google that runs the logic of deciding whether + * the registered exporter can support the incoming import request. + * + * See https://github.com/danjkim/identity-samples/tree/main/CredentialProvider/credential_exchange_matcher + * for source code and documentation. + */ + private val exportMatcher: ByteArray by lazy { + application.resources.openRawResource(R.raw.export_matcher).readBytes() + } + override suspend fun register( registrationRequest: RegistrationRequest, ): Result { @@ -47,6 +59,7 @@ internal class CredentialExchangeRegistryImpl( supportedCredentialTypes = registrationRequest.credentialTypes, ), ), + exportMatcher = exportMatcher, ) return try { providerEventsManager @@ -65,6 +78,7 @@ internal class CredentialExchangeRegistryImpl( // API is not currently available. request = RegisterExportRequest( entries = emptyList(), + exportMatcher = byteArrayOf(), ), ) .asSuccess() diff --git a/cxf/src/main/res/raw/export_matcher.bin b/cxf/src/main/res/raw/export_matcher.bin new file mode 100644 index 0000000000000000000000000000000000000000..4d2f24a1154a453e917d710ba85a4adad964bf44 GIT binary patch literal 146743 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
LqRwp&9r|bd%O~q*?$DOx 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 vxOpcoFf|Q42$!A?wP1N`dKQ^joG%EwkRhb().put("credentialTypes", any()) + } returns mockk() + + every { + anyConstructed().put("knownExtensions", any()) + } returns mockk() + } + + @AfterEach + fun tearDown() { + unmockkConstructor( + JSONObject::class, + JSONArray::class, + ) + } + @Test - fun `importCredentials should construct request correctly and return a success result`() = + fun `importCredentials should return Success when provider returns valid response`() = runTest { val mockCallingAppInfo = mockk() - val capturedRequestJson = mutableListOf() - val expectedRequestJson = """ - { - "version": { - "major":0, - "minor":0 - }, - "mode": ["direct"], - "importerRpId": "mockPackageName", - "importerDisplayName": "null", - "credentialTypes": [ - "basic-auth" - ] - } - """ - .trimIndent() coEvery { mockProviderEventsManager.importCredentials( - context = mockActivity, - request = capture(capturedRequestJson), + context = any(), + request = any(), ) } returns ProviderImportCredentialsResponse( response = ImportCredentialsResponse( @@ -60,10 +74,7 @@ class CredentialExchangeImporterTest { ) val result = importer.importCredentials(listOf("basic-auth")) - assertEquals( - expectedRequestJson, - capturedRequestJson.firstOrNull()?.requestJson, - ) + assertEquals( ImportCredentialsSelectionResult.Success( response = "mockResponse", @@ -75,38 +86,35 @@ class CredentialExchangeImporterTest { @Suppress("MaxLineLength") @Test - fun `importCredentials should return ImportCredentialsSelectionResult Cancelled when ImportCredentialsCancellationException is thrown`() = + fun `importCredentials should return Cancelled when ImportCredentialsCancellationException is thrown`() = runTest { coEvery { mockProviderEventsManager.importCredentials( - context = mockActivity, + context = any(), request = any(), ) } throws ImportCredentialsCancellationException() - assertEquals( - ImportCredentialsSelectionResult.Cancelled, - importer.importCredentials(listOf("basic-auth")), - ) + val result = importer.importCredentials(listOf("basic-auth")) + + assertEquals(ImportCredentialsSelectionResult.Cancelled, result) } @Suppress("MaxLineLength") @Test - fun `importCredentials should return ImportCredentialsSelectionResult Failure when ImportCredentialsException is thrown`() = + fun `importCredentials should return Failure with UnknownErrorException when generic Exception is thrown`() = runTest { - val importException = mockk() coEvery { mockProviderEventsManager.importCredentials( - context = mockActivity, + context = any(), request = any(), ) - } throws importException + } throws RuntimeException("Test exception") val result = importer.importCredentials(listOf("basic-auth")) - assertEquals( - ImportCredentialsSelectionResult.Failure(error = importException), - result, - ) + assertTrue(result is ImportCredentialsSelectionResult.Failure) + val failure = result as ImportCredentialsSelectionResult.Failure + assertTrue(failure.error is ImportCredentialsUnknownErrorException) } } diff --git a/cxf/src/test/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserTest.kt b/cxf/src/test/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserTest.kt new file mode 100644 index 0000000000..88b69e15ae --- /dev/null +++ b/cxf/src/test/kotlin/com/bitwarden/cxf/parser/CredentialExchangePayloadParserTest.kt @@ -0,0 +1,197 @@ +package com.bitwarden.cxf.parser + +import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException +import com.bitwarden.core.data.util.decodeFromStringOrNull +import com.bitwarden.cxf.model.CredentialExchangeExportResponse +import com.bitwarden.cxf.model.CredentialExchangePayload +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class CredentialExchangePayloadParserTest { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + private val parser = CredentialExchangePayloadParserImpl(json = json) + + @Nested + inner class PayloadParsing { + @Test + fun `parse should return Importable when payload is valid with accounts`() { + val result = parser.parse(VALID_PAYLOAD) + + assertTrue(result is CredentialExchangePayload.Importable) + val importable = result as CredentialExchangePayload.Importable + assertTrue(importable.accountsJson.isNotEmpty()) + } + + @Test + fun `parse should return NoItems when payload has empty accounts`() { + val result = parser.parse(VALID_PAYLOAD_EMPTY_ACCOUNTS) + + assertEquals(CredentialExchangePayload.NoItems, result) + } + + @Test + fun `parse should return Error when payload has unsupported major version`() { + val result = parser.parse(PAYLOAD_UNSUPPORTED_MAJOR_VERSION) + + assertTrue(result is CredentialExchangePayload.Error) + val error = (result as CredentialExchangePayload.Error).throwable + assertTrue(error is ImportCredentialsInvalidJsonException) + assertTrue(error.message?.contains("Unsupported CXF version") == true) + } + + @Test + fun `parse should return Error when payload has unsupported minor version`() { + val result = parser.parse(PAYLOAD_UNSUPPORTED_MINOR_VERSION) + + assertTrue(result is CredentialExchangePayload.Error) + val error = (result as CredentialExchangePayload.Error).throwable + assertTrue(error is ImportCredentialsInvalidJsonException) + assertTrue(error.message?.contains("Unsupported CXF version") == true) + } + } + + @Nested + inner class InvalidPayloadParsing { + @Test + fun `parse should return Error when payload is completely invalid JSON`() { + val result = parser.parse("not valid json") + + assertTrue(result is CredentialExchangePayload.Error) + val error = (result as CredentialExchangePayload.Error).throwable + assertTrue(error is ImportCredentialsInvalidJsonException) + assertTrue(error.message?.contains("Invalid Credential Exchange JSON") == true) + } + + @Test + fun `parse should return Error when payload is empty string`() { + val result = parser.parse("") + + assertTrue(result is CredentialExchangePayload.Error) + val error = (result as CredentialExchangePayload.Error).throwable + assertTrue(error is ImportCredentialsInvalidJsonException) + } + + @Test + fun `parse should return Error when payload is valid JSON but wrong structure`() { + val result = parser.parse("""{"foo": "bar"}""") + + assertTrue(result is CredentialExchangePayload.Error) + val error = (result as CredentialExchangePayload.Error).throwable + assertTrue(error is ImportCredentialsInvalidJsonException) + } + } + + @Nested + inner class SerializationErrorHandling { + @Suppress("MaxLineLength") + @Test + fun `parse should return Error when account serialization fails`() { + val mockJson = mockk { + every { + decodeFromStringOrNull(VALID_PAYLOAD) + } returns MOCK_EXPORT_RESPONSE + every { + encodeToString(any(), any()) + } throws SerializationException("Mock serialization failure") + } + val parserWithMockJson = CredentialExchangePayloadParserImpl(json = mockJson) + + val result = parserWithMockJson.parse(VALID_PAYLOAD) + + assertTrue(result is CredentialExchangePayload.Error) + val error = (result as CredentialExchangePayload.Error).throwable + assertTrue(error is ImportCredentialsInvalidJsonException) + assertTrue(error.message?.contains("Unable to serialize accounts") == true) + } + } +} + +/** + * Valid CXF payload (direct format) with version 1.0. + */ +private val VALID_PAYLOAD = """ +{ + "version": {"major": 1, "minor": 0}, + "exporterRpId": "com.example.exporter", + "exporterDisplayName": "Example Exporter", + "timestamp": 1704067200, + "accounts": [ + { + "id": "account-123", + "username": "user@example.com", + "email": "user@example.com", + "collections": [], + "items": [] + } + ] +} +""".trimIndent() + +/** + * Valid CXF payload with empty accounts list. + */ +private val VALID_PAYLOAD_EMPTY_ACCOUNTS = """ +{ + "version": {"major": 1, "minor": 0}, + "exporterRpId": "com.example.exporter", + "exporterDisplayName": "Example Exporter", + "timestamp": 1704067200, + "accounts": [] +} +""".trimIndent() + +/** + * CXF payload with unsupported major version (2.0). + */ +private val PAYLOAD_UNSUPPORTED_MAJOR_VERSION = """ +{ + "version": {"major": 2, "minor": 0}, + "exporterRpId": "com.example.exporter", + "exporterDisplayName": "Example Exporter", + "timestamp": 1704067200, + "accounts": [] +} +""".trimIndent() + +/** + * CXF payload with unsupported minor version (1.1). + */ +private val PAYLOAD_UNSUPPORTED_MINOR_VERSION = """ +{ + "version": {"major": 1, "minor": 1}, + "exporterRpId": "com.example.exporter", + "exporterDisplayName": "Example Exporter", + "timestamp": 1704067200, + "accounts": [] +} +""".trimIndent() + +/** + * Mock export response for testing serialization failures. + */ +private val MOCK_EXPORT_RESPONSE = CredentialExchangeExportResponse( + version = com.bitwarden.cxf.model.CredentialExchangeVersion(major = 1, minor = 0), + exporterRpId = "com.example.exporter", + exporterDisplayName = "Example Exporter", + timestamp = 1704067200, + accounts = listOf( + CredentialExchangeExportResponse.Account( + id = "account-123", + username = "user@example.com", + email = "user@example.com", + collections = JsonArray(emptyList()), + items = JsonArray(emptyList()), + ), + ), +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aeb290f84f..79f1c97824 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ androidxCamera = "1.5.2" androidxComposeBom = "2026.01.00" androidxCore = "1.17.0" androidxCredentials = "1.6.0-beta03" -androidxCredentialsProviderEvents = "1.0.0-alpha03" +androidxCredentialsProviderEvents = "1.0.0-alpha04" androidxHiltNavigationCompose = "1.3.0" androidxLifecycle = "2.10.0" androidxNavigation = "2.9.6"