mirror of
https://github.com/bitwarden/android.git
synced 2026-02-03 18:17:54 -06:00
[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.
This commit is contained in:
parent
954571ff4a
commit
d71150d572
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<CredentialExchangeProtocolMessage>(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<CredentialExchangeExportResponse>(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<Cipher>,
|
||||
): Result<ImportCxfPayloadResult.Success> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<JSONObject>().put(any<String>(), any<JSONArray>())
|
||||
} 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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Json> {
|
||||
every {
|
||||
decodeFromStringOrNull<CredentialExchangeProtocolMessage>(any())
|
||||
} returns DEFAULT_CXP_MESSAGE
|
||||
every {
|
||||
decodeFromStringOrNull<CredentialExchangeExportResponse>(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<ImportCiphersJsonRequest>()
|
||||
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<ImportCiphersJsonRequest>()
|
||||
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<Cipher>().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<CredentialExchangeProtocolMessage>(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<CredentialExchangeProtocolMessage>(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<CredentialExchangeProtocolMessage>(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<CredentialExchangeExportResponse>(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<CredentialExchangeExportResponse>(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<CredentialExchangeExportResponse>(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<CredentialExchangeExportResponse>(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<ImportCiphersJsonRequest>()
|
||||
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<Cipher>().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<ImportCiphersJsonRequest>()
|
||||
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<ImportCiphersJsonRequest>()
|
||||
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<ImportCiphersJsonRequest>()
|
||||
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<ImportCiphersJsonRequest>()
|
||||
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<ImportCiphersJsonRequest>()
|
||||
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<Cipher> = 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 = """
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -415,7 +415,8 @@ class ReviewExportViewModelTest : BaseViewModelTest() {
|
||||
private val MOCK_URI = mockk<Uri>()
|
||||
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(
|
||||
|
||||
@ -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)
|
||||
|
||||
24
cxf/src/main/kotlin/com/bitwarden/cxf/di/CxfModule.kt
Normal file
24
cxf/src/main/kotlin/com/bitwarden/cxf/di/CxfModule.kt
Normal file
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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<String>,
|
||||
): 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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<String>,
|
||||
val knownExtensions: Set<String>,
|
||||
) : Parcelable
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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<CredentialExchangeExportResponse>(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
|
||||
}
|
||||
@ -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<RegisterExportResponse> {
|
||||
@ -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()
|
||||
|
||||
BIN
cxf/src/main/res/raw/export_matcher.bin
Normal file
BIN
cxf/src/main/res/raw/export_matcher.bin
Normal file
Binary file not shown.
@ -4,16 +4,22 @@ import android.app.Activity
|
||||
import androidx.credentials.provider.CallingAppInfo
|
||||
import androidx.credentials.providerevents.ProviderEventsManager
|
||||
import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException
|
||||
import androidx.credentials.providerevents.exception.ImportCredentialsException
|
||||
import androidx.credentials.providerevents.transfer.ImportCredentialsRequest
|
||||
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
|
||||
import androidx.credentials.providerevents.transfer.ImportCredentialsResponse
|
||||
import androidx.credentials.providerevents.transfer.ProviderImportCredentialsResponse
|
||||
import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import io.mockk.unmockkConstructor
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CredentialExchangeImporterTest {
|
||||
@ -27,30 +33,38 @@ class CredentialExchangeImporterTest {
|
||||
providerEventsManager = mockProviderEventsManager,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkConstructor(
|
||||
JSONObject::class,
|
||||
JSONArray::class,
|
||||
)
|
||||
|
||||
every {
|
||||
anyConstructed<JSONObject>().put("credentialTypes", any<JSONArray>())
|
||||
} returns mockk()
|
||||
|
||||
every {
|
||||
anyConstructed<JSONObject>().put("knownExtensions", any<JSONArray>())
|
||||
} 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<CallingAppInfo>()
|
||||
val capturedRequestJson = mutableListOf<ImportCredentialsRequest>()
|
||||
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<ImportCredentialsException>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Json> {
|
||||
every {
|
||||
decodeFromStringOrNull<CredentialExchangeExportResponse>(VALID_PAYLOAD)
|
||||
} returns MOCK_EXPORT_RESPONSE
|
||||
every {
|
||||
encodeToString<CredentialExchangeExportResponse.Account?>(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()),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user