BIT-1294: Add autofill cipher handling (#731)

This commit is contained in:
Lucas Kivi 2024-01-23 17:04:28 -06:00 committed by Álison Fernandes
parent 8acb748782
commit 6a66d24dd1
4 changed files with 223 additions and 79 deletions

View file

@ -1,7 +1,11 @@
package com.x8bit.bitwarden.data.autofill.provider
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import kotlinx.coroutines.flow.first
@ -26,49 +30,58 @@ class AutofillCipherProviderImpl(
}
override suspend fun getCardAutofillCiphers(): List<AutofillCipher.Card> {
// TODO: fulfill with real ciphers (BIT-1294)
return if (isVaultLocked()) emptyList() else cardCiphers
val cipherViews = getUnlockedCiphersOrNull() ?: return emptyList()
return cipherViews
.mapNotNull { cipherView ->
cipherView
.takeIf { cipherView.type == CipherType.CARD }
?.let { nonNullCipherView ->
AutofillCipher.Card(
name = nonNullCipherView.name,
subtitle = nonNullCipherView.subtitle.orEmpty(),
cardholderName = nonNullCipherView.card?.cardholderName.orEmpty(),
code = nonNullCipherView.card?.code.orEmpty(),
expirationMonth = nonNullCipherView.card?.expMonth.orEmpty(),
expirationYear = nonNullCipherView.card?.expYear.orEmpty(),
number = nonNullCipherView.card?.number.orEmpty(),
)
}
}
}
override suspend fun getLoginAutofillCiphers(
uri: String,
): List<AutofillCipher.Login> {
// TODO: fulfill with real ciphers (BIT-1294)
return if (isVaultLocked()) emptyList() else loginCiphers
}
}
val cipherViews = getUnlockedCiphersOrNull() ?: return emptyList()
private val cardCiphers = listOf(
AutofillCipher.Card(
cardholderName = "John",
code = "123",
expirationMonth = "January",
expirationYear = "1999",
name = "John",
number = "1234567890",
subtitle = "123...",
),
AutofillCipher.Card(
cardholderName = "Doe",
code = "456",
expirationMonth = "December",
expirationYear = "2024",
name = "Doe",
number = "0987654321",
subtitle = "098...",
),
)
private val loginCiphers = listOf(
AutofillCipher.Login(
name = "Bitwarden1",
password = "password123",
subtitle = "John-Bitwarden",
username = "John-Bitwarden",
),
AutofillCipher.Login(
name = "Bitwarden2",
password = "password123",
subtitle = "Doe-Bitwarden",
username = "Doe-Bitwarden",
),
)
return cipherViews
.mapNotNull { cipherView ->
cipherView
.takeIf { cipherView.type == CipherType.LOGIN }
// TODO: Get global URI matching value from settings repo and
// TODO: perform more complex URI matching here (BIT-1461).
?.takeIfUriMatches(
uri = uri,
)
?.let { nonNullCipherView ->
AutofillCipher.Login(
name = nonNullCipherView.name,
password = nonNullCipherView.login?.password.orEmpty(),
subtitle = nonNullCipherView.subtitle.orEmpty(),
username = nonNullCipherView.login?.username.orEmpty(),
)
}
}
}
/**
* Get available [CipherView]s if possible.
*/
private suspend fun getUnlockedCiphersOrNull(): List<CipherView>? =
vaultRepository
.ciphersStateFlow
.takeUnless { isVaultLocked() }
?.first { it.data != null }
?.data
}

View file

@ -68,3 +68,18 @@ private val CardView.subtitleCardNumber: String?
*/
private val String?.isAmEx: Boolean
get() = this?.startsWith("34") == true || this?.startsWith("37") == true
/**
* Take this [CipherView] if its uri matches [uri]. Otherwise, return null.
*/
fun CipherView.takeIfUriMatches(
uri: String,
): CipherView? =
// TODO: Pass global URI matching value from settings (BIT-1461)
this
.takeIf {
// TODO: perform comprehensive URI matching (BIT-1461)
login
?.uris
?.any { it.uri == uri } == true
}

View file

@ -1,16 +1,26 @@
package com.x8bit.bitwarden.data.autofill.processor
import com.bitwarden.core.CardView
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.LoginView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@ -18,17 +28,41 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AutofillCipherProviderTest {
private val cardView: CardView = mockk {
every { cardholderName } returns CARD_CARDHOLDER_NAME
every { code } returns CARD_CODE
every { expMonth } returns CARD_EXP_MONTH
every { expYear } returns CARD_EXP_YEAR
every { number } returns CARD_NUMBER
}
private val cardCipherView: CipherView = mockk {
every { card } returns cardView
every { name } returns CARD_NAME
every { type } returns CipherType.CARD
}
private val loginView: LoginView = mockk {
every { password } returns LOGIN_PASSWORD
every { username } returns LOGIN_USERNAME
}
private val loginCipherView: CipherView = mockk {
every { login } returns loginView
every { name } returns LOGIN_NAME
every { type } returns CipherType.LOGIN
}
private val authRepository: AuthRepository = mockk {
every { activeUserId } returns ACTIVE_USER_ID
}
private val mutableVaultStateFlow = MutableStateFlow<VaultState>(
private val mutableVaultStateFlow = MutableStateFlow(
VaultState(
unlockingVaultUserIds = emptySet(),
unlockedVaultUserIds = emptySet(),
),
)
private val mutableCiphersStateFlow = MutableStateFlow<DataState<List<CipherView>>>(
DataState.Loading,
)
private val vaultRepository: VaultRepository = mockk {
every { ciphersStateFlow } returns mutableCiphersStateFlow
every { vaultStateFlow } returns mutableVaultStateFlow
every { isVaultUnlocked(ACTIVE_USER_ID) } answers {
ACTIVE_USER_ID in mutableVaultStateFlow.value.unlockedVaultUserIds
@ -39,12 +73,20 @@ class AutofillCipherProviderTest {
@BeforeEach
fun setup() {
mockkStatic(CipherView::takeIfUriMatches)
mockkStatic(CipherView::subtitle)
autofillCipherProvider = AutofillCipherProviderImpl(
authRepository = authRepository,
vaultRepository = vaultRepository,
)
}
@AfterEach
fun teardown() {
unmockkStatic(CipherView::takeIfUriMatches)
unmockkStatic(CipherView::subtitle)
}
@Suppress("MaxLineLength")
@Test
fun `isVaultLocked when there is no active user should return true`() =
@ -89,17 +131,28 @@ class AutofillCipherProviderTest {
}
@Test
fun `getCardAutofillCiphers when unlocked should return default list of card ciphers`() =
fun `getCardAutofillCiphers when unlocked should return non-null card ciphers`() =
runTest {
val cipherViews = listOf(
cardCipherView,
loginCipherView,
)
mutableCiphersStateFlow.value = DataState.Loaded(
data = cipherViews,
)
mutableVaultStateFlow.value = VaultState(
unlockedVaultUserIds = setOf(ACTIVE_USER_ID),
unlockingVaultUserIds = emptySet(),
)
val expected = listOf(
CARD_AUTOFILL_CIPHER,
)
every { cardCipherView.subtitle } returns CARD_SUBTITLE
// Test & Verify
val actual = autofillCipherProvider.getCardAutofillCiphers()
assertEquals(CARD_CIPHERS, actual)
assertEquals(expected, actual)
}
@Test
@ -116,19 +169,36 @@ class AutofillCipherProviderTest {
}
@Test
fun `getLoginAutofillCiphers when unlocked should return default list of login ciphers`() =
fun `getLoginAutofillCiphers when unlocked should return matching login ciphers`() =
runTest {
val unmatchedLoginCipherView: CipherView = mockk {
every { takeIfUriMatches(URI) } returns null
every { type } returns CipherType.LOGIN
}
val cipherViews = listOf(
cardCipherView,
loginCipherView,
unmatchedLoginCipherView,
)
mutableCiphersStateFlow.value = DataState.Loaded(
data = cipherViews,
)
mutableVaultStateFlow.value = VaultState(
unlockedVaultUserIds = setOf(ACTIVE_USER_ID),
unlockingVaultUserIds = emptySet(),
)
val expected = listOf(
LOGIN_AUTOFILL_CIPHER,
)
every { loginCipherView.subtitle } returns LOGIN_SUBTITLE
every { loginCipherView.takeIfUriMatches(URI) } returns loginCipherView
// Test & Verify
val actual = autofillCipherProvider.getLoginAutofillCiphers(
uri = URI,
)
assertEquals(LOGIN_CIPHERS, actual)
assertEquals(expected, actual)
}
@Test
@ -148,39 +218,34 @@ class AutofillCipherProviderTest {
}
private const val ACTIVE_USER_ID = "activeUserId"
private val CARD_CIPHERS = listOf(
AutofillCipher.Card(
cardholderName = "John",
code = "123",
expirationMonth = "January",
expirationYear = "1999",
name = "John",
number = "1234567890",
subtitle = "123...",
),
AutofillCipher.Card(
cardholderName = "Doe",
code = "456",
expirationMonth = "December",
expirationYear = "2024",
name = "Doe",
number = "0987654321",
subtitle = "098...",
),
private const val CARD_CARDHOLDER_NAME = "John Doe"
private const val CARD_CODE = "123"
private const val CARD_EXP_MONTH = "January"
private const val CARD_EXP_YEAR = "2029"
private const val CARD_NAME = "John's Card"
private const val CARD_NUMBER = "1234567890"
private const val CARD_SUBTITLE = "7890"
private val CARD_AUTOFILL_CIPHER = AutofillCipher.Card(
cardholderName = CARD_CARDHOLDER_NAME,
code = CARD_CODE,
expirationMonth = CARD_EXP_MONTH,
expirationYear = CARD_EXP_YEAR,
name = CARD_NAME,
number = CARD_NUMBER,
subtitle = CARD_SUBTITLE,
)
private val LOGIN_CIPHERS = listOf(
AutofillCipher.Login(
name = "Bitwarden1",
password = "password123",
subtitle = "John-Bitwarden",
username = "John-Bitwarden",
),
AutofillCipher.Login(
name = "Bitwarden2",
password = "password123",
subtitle = "Doe-Bitwarden",
username = "Doe-Bitwarden",
),
private const val LOGIN_NAME = "John's Login"
private const val LOGIN_PASSWORD = "Password123"
private const val LOGIN_SUBTITLE = "John Doe"
private const val LOGIN_USERNAME = "John-Bitwarden"
private val LOGIN_AUTOFILL_CIPHER = AutofillCipher.Login(
name = LOGIN_NAME,
password = LOGIN_PASSWORD,
subtitle = LOGIN_SUBTITLE,
username = LOGIN_USERNAME,
)
private val CIPHERS = listOf(
CARD_AUTOFILL_CIPHER,
LOGIN_AUTOFILL_CIPHER,
)
private const val URI: String = "androidapp://com.x8bit.bitwarden"

View file

@ -4,7 +4,10 @@ import com.bitwarden.core.CardView
import com.bitwarden.core.CipherType
import com.bitwarden.core.CipherView
import com.bitwarden.core.IdentityView
import com.bitwarden.core.LoginUriView
import com.bitwarden.core.LoginView
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.platform.util.takeIfUriMatches
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
@ -329,4 +332,52 @@ class CipherViewExtensionsTest {
// Verify
assertNull(actual)
}
@Test
fun `takeIfUriMatches should return the CipherView if the URI matches`() {
// Setup
val testUri = "com.x8bit.bitwarden"
val loginUriView: LoginUriView = mockk {
every { uri } returns testUri
}
val loginView: LoginView = mockk {
every { uris } returns listOf(loginUriView)
}
val expected: CipherView = mockk {
every { login } returns loginView
every { type } returns CipherType.IDENTITY
}
// Test
val actual = expected.takeIfUriMatches(
uri = testUri,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `takeIfUriMatches should return null if the URI doesn't match`() {
// Setup
val testUri = "com.x8bit.bitwarden"
val loginUriView: LoginUriView = mockk {
every { uri } returns "com.google"
}
val loginView: LoginView = mockk {
every { uris } returns listOf(loginUriView)
}
val cipherView: CipherView = mockk {
every { login } returns loginView
every { type } returns CipherType.IDENTITY
}
// Test
val actual = cipherView.takeIfUriMatches(
uri = testUri,
)
// Verify
assertNull(actual)
}
}